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.ExpressionAlternative;
020import com.lokalized.LocalizedString.ExpressionTranslation;
021import com.lokalized.LocalizedString.LanguageFormTranslation;
022import com.lokalized.LocalizedString.LanguageFormTranslationRange;
023import com.lokalized.LocalizedString.PlaceholderDefinition;
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.NotThreadSafe;
033import javax.annotation.concurrent.ThreadSafe;
034import java.io.ByteArrayOutputStream;
035import java.io.File;
036import java.io.IOException;
037import java.io.InputStream;
038import java.io.Reader;
039import java.net.JarURLConnection;
040import java.net.URISyntaxException;
041import java.net.URL;
042import java.net.URLClassLoader;
043import java.net.URLConnection;
044import java.nio.ByteBuffer;
045import java.nio.charset.CharacterCodingException;
046import java.nio.charset.CodingErrorAction;
047import java.nio.file.DirectoryIteratorException;
048import java.nio.file.DirectoryStream;
049import java.nio.file.FileSystemNotFoundException;
050import java.nio.file.Files;
051import java.nio.file.Path;
052import java.nio.file.Paths;
053import java.nio.file.ProviderNotFoundException;
054import java.util.ArrayList;
055import java.util.Arrays;
056import java.util.Collections;
057import java.util.Comparator;
058import java.util.Enumeration;
059import java.util.HashSet;
060import java.util.IllformedLocaleException;
061import java.util.LinkedHashMap;
062import java.util.LinkedHashSet;
063import java.util.List;
064import java.util.Locale;
065import java.util.Map;
066import java.util.Set;
067import java.util.TreeMap;
068import java.util.TreeSet;
069import java.util.jar.Attributes;
070import java.util.jar.JarEntry;
071import java.util.jar.JarFile;
072import java.util.jar.Manifest;
073import java.util.regex.Pattern;
074import java.util.stream.Collectors;
075import java.util.zip.ZipException;
076
077import static com.lokalized.Diagnostics.format;
078import static java.nio.charset.StandardCharsets.UTF_8;
079import static java.util.Objects.requireNonNull;
080
081/**
082 * Utility methods for loading localized strings files.
083 * <p>
084 * Map-returning load methods return unmodifiable maps containing unmodifiable sets. Their locales iterate in ascending
085 * {@link Locale#toLanguageTag()} order. Key lookup and map equality retain ordinary {@link Locale#equals(Object)}
086 * semantics. All {@code parse(...)} methods return unmodifiable sets.
087 * <p>
088 * A generated placeholder may be language-form-driven ({@link LocalizedString.LanguageFormTranslation}; localized strings file
089 * members {@code value} or {@code range}, plus {@code translations}) or template-driven
090 * ({@link LocalizedString.ExpressionTranslation}; a required default {@code translation}, plus optional ordered
091 * expression {@code alternatives}). Template alternatives select string fragments only; the first matching
092 * expression wins and the required default is used when none match. Placeholder modes are mutually exclusive, and
093 * all expressions and fragment placeholder references are validated while loading.
094 * <p>
095 * Unicode letter, number, and mark membership in placeholder and expression identifiers follows the executing JDK's
096 * {@link Pattern} Unicode tables. Author files for the oldest JDK in the deployment fleet when the same files must be
097 * portable across runtime versions; {@code [A-Za-z_][A-Za-z0-9_-]*} is the portable ASCII subset.
098 *
099 * @author <a href="https://revetkn.com">Mark Allen</a>
100 */
101@ThreadSafe
102public final class LocalizedStringLoader {
103  private static final int MAXIMUM_JSON_DIAGNOSTIC_PATH_CHARACTERS = 4096;
104  @NonNull
105  private static final Map<@NonNull String, @NonNull LanguageForm> SUPPORTED_LANGUAGE_FORMS_BY_NAME;
106  @NonNull
107  private static final ExpressionEvaluator EXPRESSION_EVALUATOR;
108  @NonNull
109  private static final Pattern LANGUAGE_TAG_PATTERN;
110  @NonNull
111  private static final String JSON_EXTENSION;
112  private static final char UTF_8_BOM;
113
114  static {
115    EXPRESSION_EVALUATOR = new ExpressionEvaluator(null, null, TranslationRuntimeLimits.hardCeilings());
116
117    Set<@NonNull LanguageForm> supportedLanguageForms = new LinkedHashSet<>();
118    supportedLanguageForms.addAll(Arrays.asList(Gender.values()));
119    supportedLanguageForms.addAll(Arrays.asList(GrammaticalCase.values()));
120    supportedLanguageForms.addAll(Arrays.asList(Definiteness.values()));
121    supportedLanguageForms.addAll(Arrays.asList(Classifier.values()));
122    supportedLanguageForms.addAll(Arrays.asList(Formality.values()));
123    supportedLanguageForms.addAll(Arrays.asList(Clusivity.values()));
124    supportedLanguageForms.addAll(Arrays.asList(Animacy.values()));
125    supportedLanguageForms.addAll(Arrays.asList(Cardinality.values()));
126    supportedLanguageForms.addAll(Arrays.asList(Ordinality.values()));
127    supportedLanguageForms.addAll(Arrays.asList(Phonetic.values()));
128
129    Map<@NonNull String, @NonNull LanguageForm> supportedLanguageFormsByName = new LinkedHashMap<>();
130
131    for (LanguageForm languageForm : supportedLanguageForms) {
132      if (!languageForm.getClass().isEnum())
133        throw new IllegalArgumentException(format("The %s interface must be implemented by enum types. %s is not an enum",
134            LanguageForm.class.getSimpleName(), languageForm.getClass().getSimpleName()));
135
136      String languageFormName = ((Enum<?>) languageForm).name();
137
138      // Massage Cardinality to match file format, e.g. "ONE" -> "CARDINALITY_ONE"
139      if (languageForm instanceof Cardinality)
140        languageFormName = LocalizedStringUtils.localizedStringNameForCardinalityName(languageFormName);
141
142      // Massage Ordinality to match file format, e.g. "ONE" -> "ORDINALITY_ONE"
143      if (languageForm instanceof Ordinality)
144        languageFormName = LocalizedStringUtils.localizedStringNameForOrdinalityName(languageFormName);
145
146      // Massage Gender to match file format, e.g. "MASCULINE" -> "GENDER_MASCULINE"
147      if (languageForm instanceof Gender)
148        languageFormName = LocalizedStringUtils.localizedStringNameForGenderName(languageFormName);
149
150      // Massage GrammaticalCase to match file format, e.g. "DATIVE" -> "CASE_DATIVE"
151      if (languageForm instanceof GrammaticalCase)
152        languageFormName = LocalizedStringUtils.localizedStringNameForGrammaticalCaseName(languageFormName);
153
154      // Massage Definiteness to match file format, e.g. "DEFINITE" -> "DEFINITENESS_DEFINITE"
155      if (languageForm instanceof Definiteness)
156        languageFormName = LocalizedStringUtils.localizedStringNameForDefinitenessName(languageFormName);
157
158      // Massage Classifier to match file format, e.g. "GENERAL" -> "CLASSIFIER_GENERAL"
159      if (languageForm instanceof Classifier)
160        languageFormName = LocalizedStringUtils.localizedStringNameForClassifierName(languageFormName);
161
162      // Massage Formality to match file format, e.g. "FORMAL" -> "FORMALITY_FORMAL"
163      if (languageForm instanceof Formality)
164        languageFormName = LocalizedStringUtils.localizedStringNameForFormalityName(languageFormName);
165
166      // Massage Clusivity to match file format, e.g. "INCLUSIVE" -> "CLUSIVITY_INCLUSIVE"
167      if (languageForm instanceof Clusivity)
168        languageFormName = LocalizedStringUtils.localizedStringNameForClusivityName(languageFormName);
169
170      // Massage Animacy to match file format, e.g. "ANIMATE" -> "ANIMACY_ANIMATE"
171      if (languageForm instanceof Animacy)
172        languageFormName = LocalizedStringUtils.localizedStringNameForAnimacyName(languageFormName);
173
174      // Massage Phonetic to match file format, e.g. "VOWEL" -> "PHONETIC_VOWEL"
175      if (languageForm instanceof Phonetic)
176        languageFormName = LocalizedStringUtils.localizedStringNameForPhoneticName(languageFormName);
177
178      LanguageForm existingLanguageForm = supportedLanguageFormsByName.get(languageFormName);
179
180      if (existingLanguageForm != null)
181        throw new IllegalArgumentException(format("There is already a language form %s.%s whose localized string name collides with %s.%s. " +
182                "Localized string language form names must be unique", existingLanguageForm.getClass().getSimpleName(), languageFormName,
183            languageForm.getClass().getSimpleName(), languageFormName));
184
185      supportedLanguageFormsByName.put(languageFormName, languageForm);
186    }
187
188    SUPPORTED_LANGUAGE_FORMS_BY_NAME = Collections.unmodifiableMap(supportedLanguageFormsByName);
189    LANGUAGE_TAG_PATTERN = Pattern.compile("^[A-Za-z]{1,8}(-[A-Za-z0-9]{1,8})*$");
190    JSON_EXTENSION = ".json";
191    UTF_8_BOM = '\uFEFF';
192  }
193
194  private LocalizedStringLoader() {
195    // Non-instantiable
196  }
197
198  /**
199   * Loads all localized strings files present in the specified package on the classpath.
200   * <p>
201   * Filenames must correspond to the IETF BCP 47 language tag format, optionally suffixed with {@code .json}.
202   * <p>
203   * Example filenames:
204   * <ul>
205   * <li>{@code en}</li>
206   * <li>{@code en.json}</li>
207   * <li>{@code es-MX}</li>
208   * <li>{@code es-MX.json}</li>
209   * <li>{@code nan-Hant-TW}</li>
210   * </ul>
211   * <p>
212   * Like any classpath reference, packages are separated using the {@code /} character.
213   * <p>
214   * Example package names:
215   * <ul>
216   * <li>{@code strings}
217   * <li>{@code com/example/myapp/strings} (recommended to avoid collisions with dependencies)
218   * </ul>
219   * <p>
220   * Note: this implementation only scans the specified package, it does not descend into child packages.
221   * A trailing slash is optional and is normalized before lookup.
222   * <p>
223   * The physical multi-release JAR namespace {@code META-INF/versions} and its child packages are reserved and cannot
224   * be used for package discovery. Use {@link #loadFromClasspathResources(Map)} when an exact resource beneath that
225   * namespace must be loaded.
226   * <p>
227   * By default, discovery uses {@link ClassLoader#getResources(String)} and does not inspect unrelated classpath roots.
228   * Use a {@link LocalizedStringLoadingOptions} overload with exhaustive classpath search enabled only for JARs that
229   * omit package directory entries. A classpath {@code .json} resource whose filename is not a locale tag is ignored
230   * with a warning; explicitly loaded filesystem directories containing localized strings files retain strict filename
231   * validation.
232   *
233   * @param classpathPackage location of a package on the classpath, not null
234   * @return per-locale sets of localized strings, not null
235   * @throws IllegalArgumentException if {@code classpathPackage} is invalid or names the reserved physical
236   * multi-release JAR namespace
237   * @throws LocalizedStringLoadingException if an error occurs while loading localized strings files
238   */
239  @NonNull
240  public static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromClasspath(@NonNull String classpathPackage) {
241    return loadFromClasspath(classpathPackage, LocalizedStringWarningHandler.ignore(), LocalizedStringLoadingOptions.defaults());
242  }
243
244  /**
245   * Loads localized strings files from a classpath package using the specified loading and discovery options.
246   *
247   * @param classpathPackage location of a package on the classpath, not null
248   * @param loadingOptions   loading and classpath-discovery options to apply, not null
249   * @return per-locale sets of localized strings, not null
250   * @throws IllegalArgumentException if {@code classpathPackage} is invalid or names the reserved physical
251   * multi-release JAR namespace
252   * @throws LocalizedStringLoadingException if loading, discovery, validation, or a configured limit fails
253   * @since 3.0.0
254   */
255  @NonNull
256  public static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromClasspath(
257      @NonNull String classpathPackage, @NonNull LocalizedStringLoadingOptions loadingOptions) {
258    return loadFromClasspath(classpathPackage, LocalizedStringWarningHandler.ignore(), loadingOptions);
259  }
260
261  /**
262   * Loads all localized strings files present in the specified package, routing validation warnings to the given handler.
263   *
264   * @param classpathPackage location of a package on the classpath, not null
265   * @param warningHandler   handler for non-fatal validation warnings, not null
266   * @return per-locale sets of localized strings, not null
267   * @throws IllegalArgumentException if {@code classpathPackage} is invalid or names the reserved physical
268   * multi-release JAR namespace
269   * @throws LocalizedStringLoadingException if an error occurs while loading localized strings files
270   * @since 3.0.0
271   */
272  @NonNull
273  public static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromClasspath(@NonNull String classpathPackage,
274                                                                                               @NonNull LocalizedStringWarningHandler warningHandler) {
275    return loadFromClasspath(classpathPackage, warningHandler, LocalizedStringLoadingOptions.defaults());
276  }
277
278  /**
279   * Loads localized strings files from a classpath package with validation-warning, loading, and discovery policies.
280   *
281   * @param classpathPackage location of a package on the classpath, not null
282   * @param warningHandler   handler for non-fatal validation warnings, not null
283   * @param loadingOptions   loading and classpath-discovery options to apply, not null
284   * @return per-locale sets of localized strings, not null
285   * @throws IllegalArgumentException if {@code classpathPackage} is invalid or names the reserved physical
286   * multi-release JAR namespace
287   * @throws LocalizedStringLoadingException if loading, discovery, validation, or a configured limit fails
288   * @since 3.0.0
289   */
290  @NonNull
291  public static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromClasspath(
292      @NonNull String classpathPackage, @NonNull LocalizedStringWarningHandler warningHandler,
293      @NonNull LocalizedStringLoadingOptions loadingOptions) {
294    requireNonNull(classpathPackage);
295    requireNonNull(warningHandler);
296    requireNonNull(loadingOptions);
297
298    return loadFromClasspath(defaultClassLoader(), classpathPackage, warningHandler, loadingOptions);
299  }
300
301  /**
302   * Loads all localized strings files present in the specified package using the specified classloader.
303   * <p>
304   * This is useful for containers, plugin systems, test harnesses, and other environments where the
305   * desired localized string resources are not visible to Lokalized's own defining classloader.
306   *
307   * @param classLoader classloader to search, not null
308   * @param classpathPackage location of a package on the classpath, not null
309   * @return per-locale sets of localized strings, not null
310   * @throws IllegalArgumentException if {@code classpathPackage} is invalid or names the reserved physical
311   * multi-release JAR namespace
312   * @throws LocalizedStringLoadingException if an error occurs while loading localized strings files
313   * @since 3.0.0
314   */
315  @NonNull
316  public static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromClasspath(@NonNull ClassLoader classLoader,
317                                                                                               @NonNull String classpathPackage) {
318    return loadFromClasspath(classLoader, classpathPackage, LocalizedStringWarningHandler.ignore(),
319        LocalizedStringLoadingOptions.defaults());
320  }
321
322  /**
323   * Loads localized strings files using the specified classloader and loading/discovery options.
324   *
325   * @param classLoader      classloader to search, not null
326   * @param classpathPackage location of a package on the classpath, not null
327   * @param loadingOptions   loading and classpath-discovery options to apply, not null
328   * @return per-locale sets of localized strings, not null
329   * @throws IllegalArgumentException if {@code classpathPackage} is invalid or names the reserved physical
330   * multi-release JAR namespace
331   * @throws LocalizedStringLoadingException if loading, discovery, validation, or a configured limit fails
332   * @since 3.0.0
333   */
334  @NonNull
335  public static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromClasspath(
336      @NonNull ClassLoader classLoader, @NonNull String classpathPackage,
337      @NonNull LocalizedStringLoadingOptions loadingOptions) {
338    return loadFromClasspath(classLoader, classpathPackage, LocalizedStringWarningHandler.ignore(), loadingOptions);
339  }
340
341  /**
342   * Loads all localized strings files present in the specified package using the specified classloader, routing
343   * validation warnings to the given handler.
344   *
345   * @param classLoader      classloader to search, not null
346   * @param classpathPackage location of a package on the classpath, not null
347   * @param warningHandler   handler for non-fatal validation warnings, not null
348   * @return per-locale sets of localized strings, not null
349   * @throws IllegalArgumentException if {@code classpathPackage} is invalid or names the reserved physical
350   * multi-release JAR namespace
351   * @throws LocalizedStringLoadingException if an error occurs while loading localized strings files
352   * @since 3.0.0
353   */
354  @NonNull
355  public static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromClasspath(@NonNull ClassLoader classLoader,
356                                                                                               @NonNull String classpathPackage,
357                                                                                               @NonNull LocalizedStringWarningHandler warningHandler) {
358    return loadFromClasspath(classLoader, classpathPackage, warningHandler, LocalizedStringLoadingOptions.defaults());
359  }
360
361  /**
362   * Loads localized strings files using the specified classloader, validation-warning policy, and loading/discovery
363   * options.
364   *
365   * @param classLoader      classloader to search, not null
366   * @param classpathPackage location of a package on the classpath, not null
367   * @param warningHandler   handler for non-fatal validation warnings, not null
368   * @param loadingOptions   loading and classpath-discovery options to apply, not null
369   * @return per-locale sets of localized strings, not null
370   * @throws IllegalArgumentException if {@code classpathPackage} is invalid or names the reserved physical
371   * multi-release JAR namespace
372   * @throws LocalizedStringLoadingException if loading, discovery, validation, or a configured limit fails
373   * @since 3.0.0
374   */
375  @NonNull
376  public static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromClasspath(
377      @NonNull ClassLoader classLoader, @NonNull String classpathPackage,
378      @NonNull LocalizedStringWarningHandler warningHandler,
379      @NonNull LocalizedStringLoadingOptions loadingOptions) {
380    requireNonNull(classpathPackage);
381    requireNonNull(classLoader);
382    requireNonNull(warningHandler);
383    requireNonNull(loadingOptions);
384    classpathPackage = normalizeClasspathPackage(classpathPackage);
385    validateClasspathDiscoveryPackage(classpathPackage);
386    LoadingSession loadingSession = new LoadingSession(loadingOptions, warningHandler);
387
388    Enumeration<@NonNull URL> urls;
389
390    try {
391      urls = requireNonNull(classLoader.getResources(classpathPackage));
392    } catch (IOException | RuntimeException e) {
393      throw new LocalizedStringLoadingException(format("Unable to search classpath for '%s'", classpathPackage), e);
394    }
395
396    Map<@NonNull Locale, @NonNull Map<@NonNull String, @NonNull SourceLocalizedString>> mergedByLocale = createSourceLocaleKeyMap();
397    Set<@NonNull String> processedLocations = new LinkedHashSet<>();
398    String packageDiscoverySource = format("classpath package '%s'", classpathPackage);
399
400    while (hasMoreClasspathResources(urls, classpathPackage)) {
401      URL url = nextClasspathResource(urls, classpathPackage);
402      loadingSession.discoverEntry(packageDiscoverySource);
403
404      if (!processedLocations.add(classpathLocationIdentity(url, classpathPackage)))
405        continue;
406
407      Map<@NonNull Locale, @NonNull Set<@NonNull SourceLocalizedString>> localizedStringsByLocale =
408          loadFromUrl(url, classpathPackage, loadingSession);
409      mergeLocalizedStrings(mergedByLocale, localizedStringsByLocale);
410    }
411
412    if (loadingOptions.isExhaustiveClasspathSearchEnabled()) {
413      for (Path classpathRoot : classpathRootsFor(classLoader, loadingSession)) {
414        if (isDirectoryForClasspathDiscovery(classpathRoot)) {
415          Path packageDirectory = classpathRoot.resolve(classpathPackage);
416
417          if (!isDirectoryForClasspathDiscovery(packageDirectory))
418            continue;
419
420          String locationIdentity = canonicalPathForPath(packageDirectory);
421
422          if (!processedLocations.add(locationIdentity))
423            continue;
424
425          mergeLocalizedStrings(mergedByLocale,
426              loadFromDirectoryWithOrigins(packageDirectory, loadingSession));
427          continue;
428        }
429
430        if (!isRegularFileForClasspathDiscovery(classpathRoot))
431          continue;
432
433        String packagePath = normalizedJarPackagePath(classpathPackage);
434        String locationIdentity = canonicalPathForPath(classpathRoot) + "!/" + packagePath;
435
436        if (!processedLocations.add(locationIdentity))
437          continue;
438
439        try (JarFile jarFile = new JarFile(classpathRoot.toFile())) {
440          JarPackageLoadResult jarPackageLoadResult =
441              loadFromJarFile(jarFile, packagePath, loadingSession);
442
443          if (!jarPackageLoadResult.isPackagePresent()) {
444            processedLocations.remove(locationIdentity);
445            continue;
446          }
447
448          mergeLocalizedStrings(mergedByLocale, jarPackageLoadResult.getLocalizedStringsByLocale());
449        } catch (ZipException e) {
450          processedLocations.remove(locationIdentity);
451        } catch (IOException | SecurityException e) {
452          throw new LocalizedStringLoadingException(format(
453              "Unable to load localized strings from classpath root '%s'", classpathRoot), e);
454        }
455      }
456    }
457
458    if (processedLocations.isEmpty())
459      throw new LocalizedStringLoadingException(format("Unable to find package '%s' on the classpath", classpathPackage));
460
461    return toLocalizedStringsByLocale(mergedByLocale);
462  }
463
464  private static boolean hasMoreClasspathResources(
465      @NonNull Enumeration<@NonNull URL> urls, @NonNull String classpathPackage) {
466    requireNonNull(urls);
467    requireNonNull(classpathPackage);
468
469    try {
470      return urls.hasMoreElements();
471    } catch (RuntimeException e) {
472      throw new LocalizedStringLoadingException(format(
473          "Unable to enumerate classpath resources for '%s'", classpathPackage), e);
474    }
475  }
476
477  @NonNull
478  private static URL nextClasspathResource(
479      @NonNull Enumeration<@NonNull URL> urls, @NonNull String classpathPackage) {
480    requireNonNull(urls);
481    requireNonNull(classpathPackage);
482
483    try {
484      return requireNonNull(urls.nextElement());
485    } catch (RuntimeException e) {
486      throw new LocalizedStringLoadingException(format(
487          "Unable to enumerate classpath resources for '%s'", classpathPackage), e);
488    }
489  }
490
491  /**
492   * Loads explicitly mapped resources from the current thread context classloader using
493   * {@link ClassLoader#getResourceAsStream(String)}. If no context classloader is set, Lokalized's defining classloader
494   * is used.
495   * <p>
496   * Unlike package discovery, this API does not enumerate directories or depend on {@code file}/{@code jar} URL
497   * protocols. It is therefore appropriate for containers, module systems, and plugin classloaders that can open known
498   * resources but cannot expose a scannable package URL. Resource paths must be nonempty slash-relative paths.
499   * Unlike package discovery, exact physical resources beneath {@code META-INF/versions} are permitted.
500   *
501   * @param resourcePathByLocale exact classpath resource path for each locale, not null
502   * @return per-locale sets of localized strings, not null
503   * @throws NullPointerException if the mapping or any mapping key or value is null
504   * @throws IllegalArgumentException if a locale key or resource path is invalid
505   * @throws LocalizedStringLoadingException if rendered locale tags collide, a resource cannot be loaded, parsed, or
506   * validated, or a configured loading limit is exceeded
507   * @since 3.0.0
508   */
509  @NonNull
510  public static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromClasspathResources(
511      @NonNull Map<@NonNull Locale, @NonNull String> resourcePathByLocale) {
512    return loadFromClasspathResources(defaultClassLoader(), resourcePathByLocale);
513  }
514
515  /**
516   * Loads explicitly mapped resources from the current thread context classloader with loading limits.
517   *
518   * @param resourcePathByLocale exact classpath resource path for each locale, not null
519   * @param loadingOptions resource limits to apply across the mapped resources, not null
520   * @return per-locale sets of localized strings, not null
521   * @throws NullPointerException if any argument or mapping key or value is null
522   * @throws IllegalArgumentException if a locale key or resource path is invalid
523   * @throws LocalizedStringLoadingException if rendered locale tags collide, a resource cannot be loaded, parsed, or
524   * validated, or a configured loading limit is exceeded
525   * @since 3.0.0
526   */
527  @NonNull
528  public static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromClasspathResources(
529      @NonNull Map<@NonNull Locale, @NonNull String> resourcePathByLocale,
530      @NonNull LocalizedStringLoadingOptions loadingOptions) {
531    return loadFromClasspathResources(defaultClassLoader(), resourcePathByLocale, loadingOptions);
532  }
533
534  /**
535   * Loads explicitly mapped resources from the current thread context classloader with a validation-warning policy.
536   *
537   * @param resourcePathByLocale exact classpath resource path for each locale, not null
538   * @param warningHandler handler for non-fatal validation warnings, not null
539   * @return per-locale sets of localized strings, not null
540   * @throws NullPointerException if any argument or mapping key or value is null
541   * @throws IllegalArgumentException if a locale key or resource path is invalid
542   * @throws LocalizedStringLoadingException if rendered locale tags collide, a resource cannot be loaded, parsed, or
543   * validated, or a configured loading limit is exceeded
544   * @since 3.0.0
545   */
546  @NonNull
547  public static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromClasspathResources(
548      @NonNull Map<@NonNull Locale, @NonNull String> resourcePathByLocale,
549      @NonNull LocalizedStringWarningHandler warningHandler) {
550    return loadFromClasspathResources(defaultClassLoader(), resourcePathByLocale, warningHandler);
551  }
552
553  /**
554   * Loads explicitly mapped resources from the current thread context classloader with warning and loading policies.
555   *
556   * @param resourcePathByLocale exact classpath resource path for each locale, not null
557   * @param warningHandler handler for non-fatal validation warnings, not null
558   * @param loadingOptions resource limits to apply across the mapped resources, not null
559   * @return per-locale sets of localized strings, not null
560   * @throws NullPointerException if any argument or mapping key or value is null
561   * @throws IllegalArgumentException if a locale key or resource path is invalid
562   * @throws LocalizedStringLoadingException if rendered locale tags collide, a resource cannot be loaded, parsed, or
563   * validated, or a configured loading limit is exceeded
564   * @since 3.0.0
565   */
566  @NonNull
567  public static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromClasspathResources(
568      @NonNull Map<@NonNull Locale, @NonNull String> resourcePathByLocale,
569      @NonNull LocalizedStringWarningHandler warningHandler,
570      @NonNull LocalizedStringLoadingOptions loadingOptions) {
571    return loadFromClasspathResources(defaultClassLoader(), resourcePathByLocale, warningHandler, loadingOptions);
572  }
573
574  /**
575   * Loads explicitly mapped classpath resources using {@link ClassLoader#getResourceAsStream(String)}.
576   *
577   * @param classLoader classloader from which to open resources, not null
578   * @param resourcePathByLocale exact classpath resource path for each locale, not null
579   * @return per-locale sets of localized strings, not null
580   * @throws NullPointerException if any argument or mapping key or value is null
581   * @throws IllegalArgumentException if a locale key or resource path is invalid
582   * @throws LocalizedStringLoadingException if rendered locale tags collide, a resource cannot be loaded, parsed, or
583   * validated, or a configured loading limit is exceeded
584   * @since 3.0.0
585   */
586  @NonNull
587  public static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromClasspathResources(
588      @NonNull ClassLoader classLoader, @NonNull Map<@NonNull Locale, @NonNull String> resourcePathByLocale) {
589    return loadFromClasspathResources(classLoader, resourcePathByLocale, LocalizedStringWarningHandler.ignore(),
590        LocalizedStringLoadingOptions.defaults());
591  }
592
593  /**
594   * Loads explicitly mapped classpath resources with loading limits.
595   *
596   * @param classLoader classloader from which to open resources, not null
597   * @param resourcePathByLocale exact classpath resource path for each locale, not null
598   * @param loadingOptions resource limits to apply across the mapped resources, not null
599   * @return per-locale sets of localized strings, not null
600   * @throws NullPointerException if any argument or mapping key or value is null
601   * @throws IllegalArgumentException if a locale key or resource path is invalid
602   * @throws LocalizedStringLoadingException if rendered locale tags collide, a resource cannot be loaded, parsed, or
603   * validated, or a configured loading limit is exceeded
604   * @since 3.0.0
605   */
606  @NonNull
607  public static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromClasspathResources(
608      @NonNull ClassLoader classLoader, @NonNull Map<@NonNull Locale, @NonNull String> resourcePathByLocale,
609      @NonNull LocalizedStringLoadingOptions loadingOptions) {
610    return loadFromClasspathResources(classLoader, resourcePathByLocale, LocalizedStringWarningHandler.ignore(),
611        loadingOptions);
612  }
613
614  /**
615   * Loads explicitly mapped classpath resources with a validation-warning policy.
616   *
617   * @param classLoader classloader from which to open resources, not null
618   * @param resourcePathByLocale exact classpath resource path for each locale, not null
619   * @param warningHandler handler for non-fatal validation warnings, not null
620   * @return per-locale sets of localized strings, not null
621   * @throws NullPointerException if any argument or mapping key or value is null
622   * @throws IllegalArgumentException if a locale key or resource path is invalid
623   * @throws LocalizedStringLoadingException if rendered locale tags collide, a resource cannot be loaded, parsed, or
624   * validated, or a configured loading limit is exceeded
625   * @since 3.0.0
626   */
627  @NonNull
628  public static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromClasspathResources(
629      @NonNull ClassLoader classLoader, @NonNull Map<@NonNull Locale, @NonNull String> resourcePathByLocale,
630      @NonNull LocalizedStringWarningHandler warningHandler) {
631    return loadFromClasspathResources(classLoader, resourcePathByLocale, warningHandler,
632        LocalizedStringLoadingOptions.defaults());
633  }
634
635  /**
636   * Loads explicitly mapped classpath resources with validation-warning and aggregate loading-limit policies.
637   *
638   * @param classLoader classloader from which to open resources, not null
639   * @param resourcePathByLocale exact classpath resource path for each locale, not null
640   * @param warningHandler handler for non-fatal validation warnings, not null
641   * @param loadingOptions resource limits to apply across the mapped resources, not null
642   * @return per-locale sets of localized strings, not null
643   * @throws NullPointerException if any argument or mapping key or value is null
644   * @throws IllegalArgumentException if a locale key or resource path is invalid
645   * @throws LocalizedStringLoadingException if rendered locale tags collide, a resource cannot be loaded, parsed, or
646   * validated, or a configured loading limit is exceeded
647   * @since 3.0.0
648   */
649  @NonNull
650  public static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromClasspathResources(
651      @NonNull ClassLoader classLoader, @NonNull Map<@NonNull Locale, @NonNull String> resourcePathByLocale,
652      @NonNull LocalizedStringWarningHandler warningHandler,
653      @NonNull LocalizedStringLoadingOptions loadingOptions) {
654    requireNonNull(classLoader);
655    requireNonNull(resourcePathByLocale);
656    requireNonNull(warningHandler);
657    requireNonNull(loadingOptions);
658
659    int localizedStringsFileCount = resourcePathByLocale.size();
660
661    if (localizedStringsFileCount > loadingOptions.getMaximumLocalizedStringsFiles())
662      throw new LocalizedStringLoadingException(format(
663          "Classpath resource mapping contains %d localized strings files, exceeding the aggregate localized strings file limit of %d",
664          localizedStringsFileCount, loadingOptions.getMaximumLocalizedStringsFiles()));
665
666    List<Map.@NonNull Entry<@NonNull Locale, @NonNull String>> sortedResourcePathsByLocale = new ArrayList<>();
667    Map<@NonNull String, @NonNull String> resourcePathByLanguageTag = new LinkedHashMap<>();
668
669    for (Map.Entry<@NonNull Locale, @NonNull String> resourcePathEntry : resourcePathByLocale.entrySet()) {
670      Locale locale = requireNonNull(resourcePathEntry.getKey());
671      String resourcePath = requireNonNull(resourcePathEntry.getValue());
672      validateExplicitLocale(locale);
673      validateClasspathResourcePath(resourcePath);
674      String languageTag = locale.toLanguageTag();
675      @Nullable String existingResourcePath = resourcePathByLanguageTag.putIfAbsent(languageTag, resourcePath);
676
677      if (existingResourcePath != null)
678        throw new LocalizedStringLoadingException(format(
679            "Duplicate localized strings resource mapping for locale '%s' found at '%s' and '%s'",
680            languageTag, existingResourcePath, resourcePath));
681
682      sortedResourcePathsByLocale.add(new java.util.AbstractMap.SimpleImmutableEntry<>(locale, resourcePath));
683    }
684
685    sortedResourcePathsByLocale.sort(Comparator.comparing(entry -> entry.getKey().toLanguageTag()));
686
687    LoadingSession loadingSession = new LoadingSession(loadingOptions, warningHandler);
688    Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> localizedStringsByLocale = createLocaleMap();
689
690    for (Map.Entry<@NonNull Locale, @NonNull String> resourcePathEntry : sortedResourcePathsByLocale) {
691      Locale locale = resourcePathEntry.getKey();
692      String resourcePath = resourcePathEntry.getValue();
693      String source = "classpath:" + resourcePath;
694
695      try (InputStream inputStream = classLoader.getResourceAsStream(resourcePath)) {
696        if (inputStream == null)
697          throw new LocalizedStringLoadingException(format(
698              "Unable to find localized strings resource '%s' on the classpath", resourcePath));
699
700        localizedStringsByLocale.put(locale, parse(inputStream, locale, source, loadingSession));
701      } catch (IOException e) {
702        throw new LocalizedStringLoadingException(format(
703            "Unable to load localized strings resource '%s' from the classpath", resourcePath), e);
704      }
705    }
706
707    return unmodifiableLocaleMapInLanguageTagOrder(localizedStringsByLocale);
708  }
709
710  @NonNull
711  private static ClassLoader defaultClassLoader() {
712    @Nullable ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
713
714    if (classLoader == null)
715      classLoader = LocalizedStringLoader.class.getClassLoader();
716
717    return classLoader;
718  }
719
720  @NonNull
721  private static Set<@NonNull Path> classpathRootsFor(@NonNull ClassLoader classLoader,
722                                                       @NonNull LoadingSession loadingSession) {
723    requireNonNull(classLoader);
724    requireNonNull(loadingSession);
725
726    Set<@NonNull Path> classpathRoots = new LinkedHashSet<>();
727    boolean delegatesToSystemClassLoader = false;
728    ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
729
730    for (ClassLoader current = classLoader; current != null; current = current.getParent()) {
731      if (current == systemClassLoader)
732        delegatesToSystemClassLoader = true;
733
734      if (!(current instanceof URLClassLoader))
735        continue;
736
737      for (URL url : ((URLClassLoader) current).getURLs()) {
738        loadingSession.discoverEntry("classpath roots");
739
740        if (!"file".equals(url.getProtocol()))
741          continue;
742
743        classpathRoots.add(pathForClasspathUrl(url, "classpath root"));
744      }
745    }
746
747    if (delegatesToSystemClassLoader) {
748      String classpath = System.getProperty("java.class.path", "");
749
750      for (int entryStart = 0; entryStart <= classpath.length();) {
751        int separatorIndex = classpath.indexOf(File.pathSeparatorChar, entryStart);
752        int entryEnd = separatorIndex < 0 ? classpath.length() : separatorIndex;
753
754        if (entryEnd > entryStart) {
755          // Charge the candidate before allocating a token or asking Path to parse it.
756          loadingSession.discoverEntry("system classpath roots");
757          String entry = classpath.substring(entryStart, entryEnd);
758
759          try {
760            classpathRoots.add(Paths.get(entry).toAbsolutePath().normalize());
761          } catch (IllegalArgumentException | FileSystemNotFoundException | ProviderNotFoundException
762                   | SecurityException e) {
763            throw new LocalizedStringLoadingException(format(
764                "Unable to resolve system classpath root '%s'", entry), e);
765          }
766        }
767
768        if (separatorIndex < 0)
769          break;
770        entryStart = separatorIndex + 1;
771      }
772    }
773
774    addManifestClasspathRoots(classpathRoots, loadingSession);
775
776    return Collections.unmodifiableSet(classpathRoots);
777  }
778
779  /**
780   * Expands the transitive {@code Class-Path} entries of classpath JAR manifests. {@link URLClassLoader#getURLs()}
781   * reports only the URLs supplied to the loader, even though its resource lookup also follows these manifest links.
782   */
783  private static void addManifestClasspathRoots(@NonNull Set<@NonNull Path> classpathRoots,
784                                                @NonNull LoadingSession loadingSession) {
785    requireNonNull(classpathRoots);
786    requireNonNull(loadingSession);
787
788    List<@NonNull Path> pendingRoots = new ArrayList<>(classpathRoots);
789
790    for (int rootIndex = 0; rootIndex < pendingRoots.size(); ++rootIndex) {
791      Path classpathRoot = pendingRoots.get(rootIndex);
792
793      if (!isRegularFileForClasspathDiscovery(classpathRoot))
794        continue;
795
796      try (JarFile jarFile = new JarFile(classpathRoot.toFile())) {
797        @Nullable Manifest manifest = jarFile.getManifest();
798
799        if (manifest == null)
800          continue;
801
802        @Nullable String manifestClasspath = manifest.getMainAttributes().getValue(Attributes.Name.CLASS_PATH);
803
804        if (manifestClasspath == null)
805          continue;
806
807        URL manifestBase = classpathRoot.toUri().toURL();
808        String manifestDiscoverySource = format("manifest Class-Path for '%s'", classpathRoot);
809
810        for (int entryStart = 0; entryStart < manifestClasspath.length();) {
811          while (entryStart < manifestClasspath.length()
812              && isManifestClasspathWhitespace(manifestClasspath.charAt(entryStart)))
813            ++entryStart;
814          if (entryStart >= manifestClasspath.length())
815            break;
816
817          int entryEnd = entryStart + 1;
818          while (entryEnd < manifestClasspath.length()
819              && !isManifestClasspathWhitespace(manifestClasspath.charAt(entryEnd)))
820            ++entryEnd;
821
822          // Charge the candidate before allocating a token or resolving its URL.
823          loadingSession.discoverEntry(manifestDiscoverySource);
824          String manifestEntry = manifestClasspath.substring(entryStart, entryEnd);
825          entryStart = entryEnd;
826          try {
827            URL resolvedEntry = new URL(manifestBase, manifestEntry);
828
829            // Exhaustive discovery operates on filesystem roots. Non-file resources remain available through
830            // ordinary ClassLoader resource lookup or the explicit locale-to-resource mapping API.
831            if (!"file".equals(resolvedEntry.getProtocol()))
832              continue;
833
834            Path resolvedRoot = Paths.get(resolvedEntry.toURI()).toAbsolutePath().normalize();
835
836            if (classpathRoots.add(resolvedRoot))
837              pendingRoots.add(resolvedRoot);
838          } catch (IOException | URISyntaxException | IllegalArgumentException | FileSystemNotFoundException
839                   | ProviderNotFoundException | SecurityException e) {
840            // A manifest Class-Path entry is optional. Classloaders ignore entries that cannot be resolved to usable
841            // URLs, so exhaustive filesystem discovery must do the same instead of failing an otherwise valid load.
842          }
843        }
844      } catch (ZipException e) {
845        // A regular classpath file need not be a JAR.
846      } catch (IOException | SecurityException e) {
847        throw new LocalizedStringLoadingException(format(
848            "Unable to inspect manifest Class-Path for classpath root '%s'", classpathRoot), e);
849      }
850    }
851  }
852
853  private static boolean isDirectoryForClasspathDiscovery(@NonNull Path path) {
854    requireNonNull(path);
855
856    try {
857      return Files.isDirectory(path);
858    } catch (SecurityException e) {
859      throw new LocalizedStringLoadingException(format("Unable to inspect classpath location '%s'", path), e);
860    }
861  }
862
863  private static boolean isRegularFileForClasspathDiscovery(@NonNull Path path) {
864    requireNonNull(path);
865
866    try {
867      return Files.isRegularFile(path);
868    } catch (SecurityException e) {
869      throw new LocalizedStringLoadingException(format("Unable to inspect classpath location '%s'", path), e);
870    }
871  }
872
873  private static boolean isManifestClasspathWhitespace(char character) {
874    return character == ' ' || character == '\t' || character == '\n' || character == '\u000B'
875        || character == '\f' || character == '\r';
876  }
877
878  @NonNull
879  private static Path pathForClasspathUrl(@NonNull URL url, @NonNull String locationDescription) {
880    requireNonNull(url);
881    requireNonNull(locationDescription);
882
883    try {
884      return Paths.get(url.toURI()).toAbsolutePath().normalize();
885    } catch (URISyntaxException | IllegalArgumentException | FileSystemNotFoundException | ProviderNotFoundException
886             | SecurityException e) {
887      throw new LocalizedStringLoadingException(format("Unable to resolve %s '%s'", locationDescription, url), e);
888    }
889  }
890
891  @NonNull
892  private static String classpathLocationIdentity(@NonNull URL url, @NonNull String classpathPackage) {
893    requireNonNull(url);
894    requireNonNull(classpathPackage);
895
896    if ("file".equals(url.getProtocol()))
897      return canonicalPathForPath(pathForClasspathUrl(url, "classpath location"));
898
899    if ("jar".equals(url.getProtocol())) {
900      try {
901        JarURLConnection connection = jarConnectionForUrl(url);
902        connection.setUseCaches(false);
903        URL jarFileUrl = connection.getJarFileURL();
904        String jarIdentity = jarFileUrl.toExternalForm();
905
906        if ("file".equals(jarFileUrl.getProtocol()))
907          jarIdentity = canonicalPathForPath(pathForClasspathUrl(jarFileUrl, "classpath JAR location"));
908
909        String entryName = connection.getEntryName();
910
911        if (entryName == null || entryName.isEmpty())
912          entryName = normalizedJarPackagePath(classpathPackage);
913
914        return jarIdentity + "!/" + normalizedJarPackagePath(entryName);
915      } catch (IOException | SecurityException e) {
916        throw new LocalizedStringLoadingException(format("Unable to resolve classpath location '%s'", url), e);
917      }
918    }
919
920    return url.toExternalForm();
921  }
922
923  /**
924   * Opens a JAR URL and verifies that its protocol handler honors the standard JAR-connection contract. A custom
925   * handler can legally be attached to an individual {@link URL}, so the protocol name alone does not make a cast
926   * to {@link JarURLConnection} safe.
927   */
928  @NonNull
929  private static JarURLConnection jarConnectionForUrl(@NonNull URL jarUrl) throws IOException {
930    requireNonNull(jarUrl);
931
932    URLConnection connection = jarUrl.openConnection();
933
934    if (!(connection instanceof JarURLConnection))
935      throw new IOException(format("JAR URL handler for '%s' returned '%s' instead of '%s'", jarUrl,
936          connection.getClass().getName(), JarURLConnection.class.getName()));
937
938    return (JarURLConnection) connection;
939  }
940
941  @NonNull
942  private static String normalizedJarPackagePath(@NonNull String classpathPackage) {
943    requireNonNull(classpathPackage);
944
945    String packagePath = classpathPackage;
946
947    while (!packagePath.isEmpty() && packagePath.startsWith("/"))
948      packagePath = packagePath.substring(1);
949
950    while (!packagePath.isEmpty() && packagePath.endsWith("/"))
951      packagePath = packagePath.substring(0, packagePath.length() - 1);
952
953    return packagePath;
954  }
955
956  @NonNull
957  private static String normalizeClasspathPackage(@NonNull String classpathPackage) {
958    requireNonNull(classpathPackage);
959
960    while (classpathPackage.length() > 1 && classpathPackage.endsWith("/"))
961      classpathPackage = classpathPackage.substring(0, classpathPackage.length() - 1);
962
963    return classpathPackage;
964  }
965
966  private static void validateClasspathPackage(@NonNull String classpathPackage) {
967    requireNonNull(classpathPackage);
968
969    if (classpathPackage.isEmpty() || classpathPackage.startsWith("/") ||
970        classpathPackage.indexOf('\\') >= 0)
971      throw new IllegalArgumentException(format(
972          "Classpath package '%s' must be a nonempty slash-relative resource path", classpathPackage));
973
974    for (String segment : classpathPackage.split("/", -1))
975      if (segment.isEmpty() || ".".equals(segment) || "..".equals(segment))
976        throw new IllegalArgumentException(format(
977            "Classpath package '%s' may not contain empty, '.' or '..' path segments", classpathPackage));
978
979    Path packagePath = Paths.get(classpathPackage);
980
981    boolean hasWindowsDrivePrefix = classpathPackage.length() >= 2 &&
982        Character.isLetter(classpathPackage.charAt(0)) && classpathPackage.charAt(1) == ':';
983
984    if (hasWindowsDrivePrefix || packagePath.getRoot() != null || packagePath.isAbsolute() ||
985        packagePath.normalize().startsWith(".."))
986      throw new IllegalArgumentException(format(
987          "Classpath package '%s' must remain beneath its classpath root", classpathPackage));
988  }
989
990  private static void validateClasspathDiscoveryPackage(@NonNull String classpathPackage) {
991    requireNonNull(classpathPackage);
992    validateClasspathPackage(classpathPackage);
993
994    if ("META-INF/versions".equals(classpathPackage) || classpathPackage.startsWith("META-INF/versions/"))
995      throw new IllegalArgumentException(format(
996          "Classpath package '%s' is beneath the reserved physical multi-release JAR namespace META-INF/versions; " +
997              "use loadFromClasspathResources(...) to load an exact resource", classpathPackage));
998  }
999
1000  private static void validateClasspathResourcePath(@NonNull String resourcePath) {
1001    requireNonNull(resourcePath);
1002    validateClasspathPackage(resourcePath);
1003
1004    if (resourcePath.endsWith("/"))
1005      throw new IllegalArgumentException(format(
1006          "Classpath resource '%s' must identify a file, not a package", resourcePath));
1007  }
1008
1009  /**
1010   * Loads all localized strings files present in the specified directory.
1011   * <p>
1012   * Filenames must correspond to the IETF BCP 47 language tag format, optionally suffixed with {@code .json}.
1013   * <p>
1014   * Example filenames:
1015   * <ul>
1016   * <li>{@code en}</li>
1017   * <li>{@code en.json}</li>
1018   * <li>{@code es-MX}</li>
1019   * <li>{@code es-MX.json}</li>
1020   * <li>{@code nan-Hant-TW}</li>
1021   * </ul>
1022   * <p>
1023   * Note: this implementation only scans the specified directory, it does not descend into child directories.
1024   *
1025   * @param directory directory in which to search for localized strings files, not null
1026   * @return per-locale sets of localized strings, not null
1027   * @throws LocalizedStringLoadingException if an error occurs while loading localized strings files
1028   */
1029  @NonNull
1030  public static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromFilesystem(@NonNull Path directory) {
1031    return loadFromFilesystem(directory, LocalizedStringWarningHandler.ignore(), LocalizedStringLoadingOptions.defaults());
1032  }
1033
1034  /**
1035   * Loads localized strings files from a directory using the specified resource limits.
1036   *
1037   * @param directory      directory in which to search, not null
1038   * @param loadingOptions resource limits to apply, not null
1039   * @return per-locale sets of localized strings, not null
1040   * @throws LocalizedStringLoadingException if an error occurs while loading localized strings files
1041   * @since 3.0.0
1042   */
1043  @NonNull
1044  public static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromFilesystem(
1045      @NonNull Path directory, @NonNull LocalizedStringLoadingOptions loadingOptions) {
1046    return loadFromFilesystem(directory, LocalizedStringWarningHandler.ignore(), loadingOptions);
1047  }
1048
1049  /**
1050   * Loads all localized strings files present in the specified directory, routing validation warnings to the given handler.
1051   *
1052   * @param directory      directory in which to search for localized strings files, not null
1053   * @param warningHandler handler for non-fatal validation warnings, not null
1054   * @return per-locale sets of localized strings, not null
1055   * @throws LocalizedStringLoadingException if an error occurs while loading localized strings files
1056   * @since 3.0.0
1057   */
1058  @NonNull
1059  public static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromFilesystem(@NonNull Path directory,
1060                                                                                                @NonNull LocalizedStringWarningHandler warningHandler) {
1061    return loadFromFilesystem(directory, warningHandler, LocalizedStringLoadingOptions.defaults());
1062  }
1063
1064  /**
1065   * Loads localized strings files from a directory with validation-warning and resource-limit policies.
1066   *
1067   * @param directory      directory in which to search, not null
1068   * @param warningHandler handler for non-fatal validation warnings, not null
1069   * @param loadingOptions resource limits to apply, not null
1070   * @return per-locale sets of localized strings, not null
1071   * @throws LocalizedStringLoadingException if an error occurs while loading localized strings files
1072   * @since 3.0.0
1073   */
1074  @NonNull
1075  public static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromFilesystem(
1076      @NonNull Path directory, @NonNull LocalizedStringWarningHandler warningHandler,
1077      @NonNull LocalizedStringLoadingOptions loadingOptions) {
1078    requireNonNull(directory);
1079    requireNonNull(warningHandler);
1080    requireNonNull(loadingOptions);
1081    return loadFromDirectory(directory, new LoadingSession(loadingOptions, warningHandler));
1082  }
1083
1084  /**
1085   * Parses one localized strings file for the given locale.
1086   *
1087   * @param path   file to parse, not null
1088   * @param locale locale represented by the file, not null
1089   * @return unmodifiable localized strings contained in the file, not null
1090   * @throws IllegalArgumentException if {@code locale} is not a well-formed IETF BCP 47 locale
1091   * @throws LocalizedStringLoadingException if the file cannot be read or is invalid
1092   * @since 3.0.0
1093   */
1094  @NonNull
1095  public static Set<@NonNull LocalizedString> parse(@NonNull Path path, @NonNull Locale locale) {
1096    return parse(path, locale, LocalizedStringWarningHandler.ignore(), LocalizedStringLoadingOptions.defaults());
1097  }
1098
1099  /**
1100   * Parses one localized strings file with validation-warning and resource-limit policies.
1101   *
1102   * @param path           file to parse, not null
1103   * @param locale         locale represented by the file, not null
1104   * @param warningHandler handler for non-fatal validation warnings, not null
1105   * @param loadingOptions resource limits to apply, not null
1106   * @return unmodifiable localized strings contained in the file, not null
1107   * @throws IllegalArgumentException if {@code locale} is not a well-formed IETF BCP 47 locale
1108   * @throws LocalizedStringLoadingException if the file cannot be read or is invalid
1109   * @since 3.0.0
1110   */
1111  @NonNull
1112  public static Set<@NonNull LocalizedString> parse(@NonNull Path path, @NonNull Locale locale,
1113                                                    @NonNull LocalizedStringWarningHandler warningHandler,
1114                                                    @NonNull LocalizedStringLoadingOptions loadingOptions) {
1115    requireNonNull(path);
1116    requireNonNull(locale);
1117    requireNonNull(warningHandler);
1118    requireNonNull(loadingOptions);
1119    LocaleUtils.requireWellFormed(locale, "Locale");
1120    return parseLocalizedStringsFile(path, locale, warningHandler, loadingOptions);
1121  }
1122
1123  /**
1124   * Parses one UTF-8 localized strings resource for the given locale. This method does not close the stream.
1125   *
1126   * @param inputStream UTF-8 resource contents, not null
1127   * @param locale      locale represented by the resource, not null
1128   * @param source      human-readable source identifier used in diagnostics, not null
1129   * @return unmodifiable localized strings contained in the resource, not null
1130   * @throws IllegalArgumentException if {@code locale} is not a well-formed IETF BCP 47 locale
1131   * @throws LocalizedStringLoadingException if the resource cannot be read or is invalid UTF-8/JSON
1132   * @since 3.0.0
1133   */
1134  @NonNull
1135  public static Set<@NonNull LocalizedString> parse(@NonNull InputStream inputStream, @NonNull Locale locale,
1136                                                    @NonNull String source) {
1137    return parse(inputStream, locale, source, LocalizedStringWarningHandler.ignore(),
1138        LocalizedStringLoadingOptions.defaults());
1139  }
1140
1141  /**
1142   * Parses one UTF-8 localized strings resource with validation-warning and resource-limit policies.
1143   * This method does not close the stream.
1144   *
1145   * @param inputStream    UTF-8 resource contents, not null
1146   * @param locale         locale represented by the resource, not null
1147   * @param source         human-readable source identifier used in diagnostics, not null
1148   * @param warningHandler handler for non-fatal validation warnings, not null
1149   * @param loadingOptions resource limits to apply, not null
1150   * @return unmodifiable localized strings contained in the resource, not null
1151   * @throws IllegalArgumentException if {@code locale} is not a well-formed IETF BCP 47 locale
1152   * @throws LocalizedStringLoadingException if the resource cannot be read or is invalid UTF-8/JSON
1153   * @since 3.0.0
1154   */
1155  @NonNull
1156  public static Set<@NonNull LocalizedString> parse(@NonNull InputStream inputStream, @NonNull Locale locale,
1157                                                    @NonNull String source,
1158                                                    @NonNull LocalizedStringWarningHandler warningHandler,
1159                                                    @NonNull LocalizedStringLoadingOptions loadingOptions) {
1160    requireNonNull(inputStream);
1161    requireNonNull(locale);
1162    requireNonNull(source);
1163    requireNonNull(warningHandler);
1164    requireNonNull(loadingOptions);
1165    LocaleUtils.requireWellFormed(locale, "Locale");
1166
1167    return parse(inputStream, locale, source, new LoadingSession(loadingOptions, warningHandler));
1168  }
1169
1170  /**
1171   * Parses one localized strings character resource for the given locale. This method does not close the reader.
1172   *
1173   * @param reader character resource contents, not null
1174   * @param locale locale represented by the resource, not null
1175   * @param source human-readable source identifier used in diagnostics, not null
1176   * @return unmodifiable localized strings contained in the resource, not null
1177   * @throws IllegalArgumentException if {@code locale} is not a well-formed IETF BCP 47 locale
1178   * @throws LocalizedStringLoadingException if the resource cannot be read or is invalid
1179   * @since 3.0.0
1180   */
1181  @NonNull
1182  public static Set<@NonNull LocalizedString> parse(@NonNull Reader reader, @NonNull Locale locale,
1183                                                    @NonNull String source) {
1184    return parse(reader, locale, source, LocalizedStringWarningHandler.ignore(), LocalizedStringLoadingOptions.defaults());
1185  }
1186
1187  /**
1188   * Parses one localized strings character resource with validation-warning and resource-limit policies.
1189   * This method does not close the reader.
1190   *
1191   * @param reader         character resource contents, not null
1192   * @param locale         locale represented by the resource, not null
1193   * @param source         human-readable source identifier used in diagnostics, not null
1194   * @param warningHandler handler for non-fatal validation warnings, not null
1195   * @param loadingOptions resource limits to apply, not null
1196   * @return unmodifiable localized strings contained in the resource, not null
1197   * @throws IllegalArgumentException if {@code locale} is not a well-formed IETF BCP 47 locale
1198   * @throws LocalizedStringLoadingException if the resource cannot be read or is invalid
1199   * @since 3.0.0
1200   */
1201  @NonNull
1202  public static Set<@NonNull LocalizedString> parse(@NonNull Reader reader, @NonNull Locale locale,
1203                                                    @NonNull String source,
1204                                                    @NonNull LocalizedStringWarningHandler warningHandler,
1205                                                    @NonNull LocalizedStringLoadingOptions loadingOptions) {
1206    requireNonNull(reader);
1207    requireNonNull(locale);
1208    requireNonNull(source);
1209    requireNonNull(warningHandler);
1210    requireNonNull(loadingOptions);
1211    LocaleUtils.requireWellFormed(locale, "Locale");
1212
1213    LoadingSession loadingSession = new LoadingSession(loadingOptions, warningHandler);
1214    loadingSession.beginLocalizedStringsFile(source);
1215    String contents = readCharacters(reader, source, loadingOptions);
1216    return parseLocalizedStrings(source, contents, locale, loadingSession);
1217  }
1218
1219  /**
1220   * Loads all localized strings files present in the specified directory.
1221   *
1222   * @param directory directory in which to search for localized strings files, not null
1223   * @return per-locale sets of localized strings, not null
1224   * @throws LocalizedStringLoadingException if an error occurs while loading localized strings files
1225   */
1226  @NonNull
1227  private static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> loadFromDirectory(@NonNull Path directory,
1228                                                                                               @NonNull LoadingSession loadingSession) {
1229    requireNonNull(directory);
1230    requireNonNull(loadingSession);
1231
1232    if (!Files.exists(directory))
1233      throw new LocalizedStringLoadingException(format("Location '%s' does not exist",
1234          directory));
1235
1236    if (!Files.isDirectory(directory))
1237      throw new LocalizedStringLoadingException(format("Location '%s' exists but is not a directory",
1238          directory));
1239
1240    Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> localizedStringsByLocale = createLocaleMap();
1241    String discoverySource = format("filesystem directory '%s'", directory);
1242
1243    try (DirectoryStream<@NonNull Path> directoryStream = Files.newDirectoryStream(directory)) {
1244      for (Path file : directoryStream) {
1245        loadingSession.discoverEntry(discoverySource);
1246
1247        if (Files.isDirectory(file))
1248          continue;
1249
1250        @Nullable Path fileNamePath = file.getFileName();
1251
1252        if (fileNamePath == null)
1253          continue;
1254
1255        String fileName = fileNamePath.toString();
1256
1257        if (isHiddenFileName(fileName))
1258          continue;
1259
1260        String languageTag = languageTagForFileName(fileName);
1261
1262        if (languageTag != null) {
1263          Locale locale = Locale.forLanguageTag(languageTag);
1264
1265          if (localizedStringsByLocale.containsKey(locale))
1266            throw new LocalizedStringLoadingException(format("Duplicate localized strings file for locale '%s' found at '%s'",
1267                locale.toLanguageTag(), file));
1268
1269          localizedStringsByLocale.put(locale, parseLocalizedStringsFile(file, locale, loadingSession));
1270        }
1271      }
1272    } catch (DirectoryIteratorException e) {
1273      throw new LocalizedStringLoadingException(format("Unable to list files in directory '%s'", directory), e.getCause());
1274    } catch (IOException e) {
1275      throw new LocalizedStringLoadingException(format("Unable to list files in directory '%s'", directory), e);
1276    }
1277
1278    return unmodifiableLocaleMapInLanguageTagOrder(localizedStringsByLocale);
1279  }
1280
1281  @NonNull
1282  private static Map<@NonNull Locale, @NonNull Set<@NonNull SourceLocalizedString>> loadFromDirectoryWithOrigins(@NonNull Path directory,
1283                                                                                                                @NonNull LoadingSession loadingSession) {
1284    requireNonNull(directory);
1285    requireNonNull(loadingSession);
1286
1287    if (!Files.exists(directory))
1288      throw new LocalizedStringLoadingException(format("Location '%s' does not exist",
1289          directory));
1290
1291    if (!Files.isDirectory(directory))
1292      throw new LocalizedStringLoadingException(format("Location '%s' exists but is not a directory",
1293          directory));
1294
1295    Map<@NonNull Locale, @NonNull Set<@NonNull SourceLocalizedString>> localizedStringsByLocale = createSourceLocaleMap();
1296    Map<@NonNull Locale, @NonNull String> originByLocale = createLocaleOriginMap();
1297    String discoverySource = format("classpath directory '%s'", directory);
1298
1299    try (DirectoryStream<@NonNull Path> directoryStream = Files.newDirectoryStream(directory)) {
1300      for (Path file : directoryStream) {
1301        loadingSession.discoverEntry(discoverySource);
1302
1303        if (Files.isDirectory(file))
1304          continue;
1305
1306        @Nullable Path fileNamePath = file.getFileName();
1307
1308        if (fileNamePath == null)
1309          continue;
1310
1311        String fileName = fileNamePath.toString();
1312
1313        if (isHiddenFileName(fileName))
1314          continue;
1315
1316        String unresolvedPath = file.toAbsolutePath().normalize().toString();
1317        @Nullable String languageTag = languageTagForClasspathFileName(fileName, unresolvedPath, loadingSession);
1318
1319        if (languageTag != null) {
1320          String canonicalPath = canonicalPathForPath(file);
1321          Locale locale = Locale.forLanguageTag(languageTag);
1322          @Nullable String existingOrigin = originByLocale.get(locale);
1323
1324          if (existingOrigin != null)
1325            throw new LocalizedStringLoadingException(format("Duplicate localized strings file for locale '%s' found in '%s' and '%s'",
1326                locale.toLanguageTag(), existingOrigin, canonicalPath));
1327
1328          localizedStringsByLocale.put(locale, sourceLocalizedStrings(
1329              parseLocalizedStringsFile(file, locale, loadingSession), canonicalPath));
1330          originByLocale.put(locale, canonicalPath);
1331        }
1332      }
1333    } catch (DirectoryIteratorException e) {
1334      throw new LocalizedStringLoadingException(format("Unable to list files in directory '%s'", directory), e.getCause());
1335    } catch (IOException e) {
1336      throw new LocalizedStringLoadingException(format("Unable to list files in directory '%s'", directory), e);
1337    }
1338
1339    return unmodifiableLocaleMapInLanguageTagOrder(localizedStringsByLocale);
1340  }
1341
1342  @NonNull
1343  private static Map<@NonNull Locale, @NonNull Set<@NonNull SourceLocalizedString>> loadFromUrl(@NonNull URL url, @NonNull String classpathPackage,
1344                                                                                               @NonNull LoadingSession loadingSession) {
1345    requireNonNull(url);
1346    requireNonNull(classpathPackage);
1347    requireNonNull(loadingSession);
1348
1349    String protocol = url.getProtocol();
1350
1351    if ("file".equals(protocol))
1352      return loadFromDirectoryWithOrigins(pathForClasspathUrl(url, "classpath location"), loadingSession);
1353
1354    if ("jar".equals(protocol))
1355      return loadFromJar(url, classpathPackage, loadingSession);
1356
1357    throw new LocalizedStringLoadingException(format("Unsupported classpath protocol '%s' for location '%s'",
1358        protocol, url));
1359  }
1360
1361  @NonNull
1362  private static Map<@NonNull Locale, @NonNull Set<@NonNull SourceLocalizedString>> loadFromJar(@NonNull URL jarUrl,
1363                                                               @NonNull String classpathPackage,
1364                                                               @NonNull LoadingSession loadingSession) {
1365    requireNonNull(jarUrl);
1366    requireNonNull(classpathPackage);
1367    requireNonNull(loadingSession);
1368
1369    try {
1370      JarURLConnection connection = jarConnectionForUrl(jarUrl);
1371      connection.setUseCaches(false);
1372
1373      try (JarFile jarFile = connection.getJarFile()) {
1374        String packagePath = connection.getEntryName();
1375
1376        if (packagePath == null || packagePath.isEmpty())
1377          packagePath = classpathPackage;
1378
1379        return loadFromJarFile(jarFile, packagePath, loadingSession).getLocalizedStringsByLocale();
1380      }
1381    } catch (IOException | SecurityException e) {
1382      throw new LocalizedStringLoadingException(format("Unable to load localized strings from '%s'", jarUrl), e);
1383    }
1384  }
1385
1386  @NonNull
1387  private static JarPackageLoadResult loadFromJarFile(
1388      @NonNull JarFile jarFile, @NonNull String packagePath,
1389      @NonNull LoadingSession loadingSession) throws IOException {
1390    requireNonNull(jarFile);
1391    requireNonNull(packagePath);
1392    requireNonNull(loadingSession);
1393
1394    Map<@NonNull Locale, @NonNull Set<@NonNull SourceLocalizedString>> localizedStringsByLocale = createSourceLocaleMap();
1395    Map<@NonNull Locale, @NonNull String> originByLocale = createLocaleOriginMap();
1396    packagePath = normalizedJarPackagePath(packagePath) + "/";
1397    EffectiveJarEntries effectiveJarEntries =
1398        effectiveJarEntriesInPackage(jarFile, packagePath, loadingSession);
1399
1400    for (Map.Entry<@NonNull String, @NonNull JarEntry> entryByRelativeName
1401        : effectiveJarEntries.getEntriesByRelativeName().entrySet()) {
1402      String relativeName = entryByRelativeName.getKey();
1403      JarEntry entry = entryByRelativeName.getValue();
1404      String entryName = entry.getName();
1405
1406      if (isHiddenFileName(relativeName))
1407        continue;
1408
1409      String canonicalPath = format("jar:%s!/%s", jarFile.getName(), entryName);
1410      @Nullable String languageTag = languageTagForClasspathFileName(relativeName, canonicalPath, loadingSession);
1411
1412      if (languageTag == null)
1413        continue;
1414
1415      Locale locale = Locale.forLanguageTag(languageTag);
1416      @Nullable String existingOrigin = originByLocale.get(locale);
1417
1418      if (existingOrigin != null)
1419        throw new LocalizedStringLoadingException(format("Duplicate localized strings file for locale '%s' found in '%s' and '%s'",
1420            locale.toLanguageTag(), existingOrigin, canonicalPath));
1421
1422      try (InputStream inputStream = jarFile.getInputStream(entry)) {
1423        Set<@NonNull LocalizedString> localizedStrings = parse(inputStream, locale, canonicalPath, loadingSession);
1424        localizedStringsByLocale.put(locale, sourceLocalizedStrings(localizedStrings, canonicalPath));
1425        originByLocale.put(locale, canonicalPath);
1426      }
1427    }
1428
1429    return new JarPackageLoadResult(
1430        unmodifiableLocaleMapInLanguageTagOrder(localizedStringsByLocale), effectiveJarEntries.isPackagePresent());
1431  }
1432
1433  /**
1434   * Selects the effective direct children of a JAR package, honoring the runtime view of multi-release JARs. Directly
1435   * iterating {@link JarFile#entries()} exposes physical base and versioned entries and therefore bypasses the resource
1436   * selection that a classloader would perform.
1437   */
1438  @NonNull
1439  private static EffectiveJarEntries effectiveJarEntriesInPackage(
1440      @NonNull JarFile jarFile, @NonNull String packagePath, @NonNull LoadingSession loadingSession) {
1441    requireNonNull(jarFile);
1442    requireNonNull(packagePath);
1443    requireNonNull(loadingSession);
1444
1445    Map<@NonNull String, @NonNull JarEntrySelection> selectionsByRelativeName = new TreeMap<>();
1446    Map<@NonNull String, @NonNull Set<@NonNull Integer>> versionsByRelativeName = new LinkedHashMap<>();
1447    Enumeration<@NonNull JarEntry> entries = jarFile.entries();
1448    boolean multiRelease = jarFile.isMultiRelease();
1449    int runtimeMajorVersion = JarFile.runtimeVersion().major();
1450    String versionedPrefix = "META-INF/versions/";
1451    String discoverySource = format("JAR '%s'", jarFile.getName());
1452    boolean packagePresent = false;
1453
1454    while (entries.hasMoreElements()) {
1455      JarEntry entry = entries.nextElement();
1456      loadingSession.discoverEntry(discoverySource);
1457
1458      String logicalEntryName = entry.getName();
1459      int version = 0;
1460
1461      if (multiRelease && logicalEntryName.startsWith(versionedPrefix)) {
1462        int versionEnd = logicalEntryName.indexOf('/', versionedPrefix.length());
1463
1464        if (versionEnd < 0)
1465          continue;
1466
1467        String versionName = logicalEntryName.substring(versionedPrefix.length(), versionEnd);
1468
1469        // The JAR specification defines a version directory as N = [1-9][0-9]*. Integer.parseInt() alone is
1470        // intentionally more permissive (for example, it accepts "09", "+9", and non-ASCII decimal digits).
1471        if (!isCanonicalMultiReleaseJarVersion(versionName))
1472          continue;
1473
1474        try {
1475          version = Integer.parseInt(versionName);
1476        } catch (NumberFormatException e) {
1477          continue;
1478        }
1479
1480        if (version < 9 || version > runtimeMajorVersion)
1481          continue;
1482
1483                                String versionedLogicalEntryName = logicalEntryName.substring(versionEnd + 1);
1484
1485                                // The multi-release JAR contract never overlays resources whose logical name is itself under META-INF/.
1486                                if (versionedLogicalEntryName.startsWith("META-INF/"))
1487                                        continue;
1488
1489                                logicalEntryName = versionedLogicalEntryName;
1490      }
1491
1492      if (logicalEntryName.equals(packagePath) || logicalEntryName.startsWith(packagePath))
1493        packagePresent = true;
1494
1495      if (entry.isDirectory() || !logicalEntryName.startsWith(packagePath))
1496        continue;
1497
1498      String relativeName = logicalEntryName.substring(packagePath.length());
1499
1500      if (relativeName.isEmpty() || relativeName.contains("/"))
1501        continue;
1502
1503      Set<@NonNull Integer> versions = versionsByRelativeName.computeIfAbsent(
1504          relativeName, ignored -> new HashSet<>());
1505
1506      if (!versions.add(version))
1507        throw new LocalizedStringLoadingException(format(
1508            "Duplicate physical JAR entry '%s' maps to logical resource '%s' at %s in JAR '%s'",
1509            entry.getName(), logicalEntryName,
1510            version == 0 ? "the base version" : format("multi-release version %d", version), jarFile.getName()));
1511
1512      @Nullable JarEntrySelection existingSelection = selectionsByRelativeName.get(relativeName);
1513
1514      if (existingSelection == null || version > existingSelection.getVersion())
1515        selectionsByRelativeName.put(relativeName, new JarEntrySelection(entry, version));
1516    }
1517
1518    Map<@NonNull String, @NonNull JarEntry> entriesByRelativeName = new LinkedHashMap<>();
1519
1520    for (Map.Entry<@NonNull String, @NonNull JarEntrySelection> selection : selectionsByRelativeName.entrySet())
1521      entriesByRelativeName.put(selection.getKey(), selection.getValue().getJarEntry());
1522
1523    return new EffectiveJarEntries(Collections.unmodifiableMap(entriesByRelativeName), packagePresent);
1524  }
1525
1526  private static boolean isCanonicalMultiReleaseJarVersion(@NonNull String version) {
1527    requireNonNull(version);
1528
1529    if (version.isEmpty() || version.charAt(0) < '1' || version.charAt(0) > '9')
1530      return false;
1531
1532    for (int i = 1; i < version.length(); ++i)
1533      if (version.charAt(i) < '0' || version.charAt(i) > '9')
1534        return false;
1535
1536    return true;
1537  }
1538
1539  @NonNull
1540  private static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> createLocaleMap() {
1541    return new LinkedHashMap<>();
1542  }
1543
1544  @NonNull
1545  private static Map<@NonNull Locale, @NonNull Set<@NonNull SourceLocalizedString>> createSourceLocaleMap() {
1546    return new LinkedHashMap<>();
1547  }
1548
1549  @NonNull
1550  private static Map<@NonNull Locale, @NonNull String> createLocaleOriginMap() {
1551    return new LinkedHashMap<>();
1552  }
1553
1554  @NonNull
1555  private static Map<@NonNull Locale, @NonNull Map<@NonNull String, @NonNull SourceLocalizedString>> createSourceLocaleKeyMap() {
1556    return new LinkedHashMap<>();
1557  }
1558
1559  @NonNull
1560  private static <V extends @NonNull Object> Map<@NonNull Locale, V> unmodifiableLocaleMapInLanguageTagOrder(
1561      @NonNull Map<@NonNull Locale, V> valuesByLocale) {
1562    requireNonNull(valuesByLocale);
1563
1564    List<Map.Entry<@NonNull Locale, V>> entries = new ArrayList<>(valuesByLocale.entrySet());
1565    entries.sort(Comparator.comparing(entry -> entry.getKey().toLanguageTag()));
1566    Map<@NonNull Locale, V> sortedValuesByLocale = new LinkedHashMap<>();
1567    Set<@NonNull String> languageTags = new HashSet<>();
1568
1569    for (Map.Entry<@NonNull Locale, V> entry : entries) {
1570      Locale locale = entry.getKey();
1571      String languageTag = locale.toLanguageTag();
1572
1573      if (!languageTags.add(languageTag.toLowerCase(Locale.ROOT)))
1574        throw new LocalizedStringLoadingException(format(
1575            "Duplicate locale key rendering as language tag '%s'", languageTag));
1576
1577      sortedValuesByLocale.put(locale, entry.getValue());
1578    }
1579
1580    return Collections.unmodifiableMap(sortedValuesByLocale);
1581  }
1582
1583  private static void mergeLocalizedStrings(
1584      @NonNull Map<@NonNull Locale, @NonNull Map<@NonNull String, @NonNull SourceLocalizedString>> target,
1585      @NonNull Map<@NonNull Locale, @NonNull Set<@NonNull SourceLocalizedString>> source) {
1586    requireNonNull(target);
1587    requireNonNull(source);
1588
1589    for (Map.Entry<@NonNull Locale, @NonNull Set<@NonNull SourceLocalizedString>> entry : source.entrySet()) {
1590      Locale locale = entry.getKey();
1591      Map<@NonNull String, @NonNull SourceLocalizedString> localizedStringsByKey = target.get(locale);
1592
1593      if (localizedStringsByKey == null) {
1594        localizedStringsByKey = new LinkedHashMap<>();
1595        target.put(locale, localizedStringsByKey);
1596      }
1597
1598      for (SourceLocalizedString sourceLocalizedString : entry.getValue()) {
1599        LocalizedString localizedString = sourceLocalizedString.getLocalizedString();
1600        String key = localizedString.getKey();
1601        SourceLocalizedString existing = localizedStringsByKey.get(key);
1602
1603        if (existing != null) {
1604          if (existing.getLocalizedString().equals(localizedString))
1605            continue;
1606
1607          throw new LocalizedStringLoadingException(format("Duplicate localized string key '%s' found for locale '%s' while merging classpath resources. " +
1608                  "Conflicting resources are '%s' and '%s'", key, locale.toLanguageTag(), existing.getOrigin(), sourceLocalizedString.getOrigin()));
1609        }
1610
1611        localizedStringsByKey.put(key, sourceLocalizedString);
1612      }
1613    }
1614  }
1615
1616  @NonNull
1617  private static Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> toLocalizedStringsByLocale(
1618      @NonNull Map<@NonNull Locale, @NonNull Map<@NonNull String, @NonNull SourceLocalizedString>> localizedStringsByKeyByLocale) {
1619    requireNonNull(localizedStringsByKeyByLocale);
1620
1621    Map<@NonNull Locale, @NonNull Set<@NonNull LocalizedString>> localizedStringsByLocale = createLocaleMap();
1622
1623    for (Map.Entry<@NonNull Locale, @NonNull Map<@NonNull String, @NonNull SourceLocalizedString>> entry : localizedStringsByKeyByLocale.entrySet()) {
1624      Set<@NonNull LocalizedString> localizedStrings = new LinkedHashSet<>();
1625
1626      for (SourceLocalizedString sourceLocalizedString : entry.getValue().values())
1627        localizedStrings.add(sourceLocalizedString.getLocalizedString());
1628
1629      localizedStringsByLocale.put(entry.getKey(), Collections.unmodifiableSet(localizedStrings));
1630    }
1631
1632    return unmodifiableLocaleMapInLanguageTagOrder(localizedStringsByLocale);
1633  }
1634
1635  @NonNull
1636  private static Set<@NonNull SourceLocalizedString> sourceLocalizedStrings(@NonNull Set<@NonNull LocalizedString> localizedStrings,
1637                                                                            @NonNull String origin) {
1638    requireNonNull(localizedStrings);
1639    requireNonNull(origin);
1640
1641    Set<@NonNull SourceLocalizedString> sourceLocalizedStrings = new LinkedHashSet<>();
1642
1643    for (LocalizedString localizedString : localizedStrings)
1644      sourceLocalizedStrings.add(new SourceLocalizedString(localizedString, origin));
1645
1646    return Collections.unmodifiableSet(sourceLocalizedStrings);
1647  }
1648
1649  private static boolean isLanguageTag(@NonNull String languageTag) {
1650    requireNonNull(languageTag);
1651
1652    if (!LANGUAGE_TAG_PATTERN.matcher(languageTag).matches())
1653      return false;
1654
1655    Locale locale;
1656
1657    try {
1658      locale = new Locale.Builder().setLanguageTag(languageTag).build();
1659    } catch (IllformedLocaleException e) {
1660      return false;
1661    }
1662
1663    if (languageTag.toLowerCase(Locale.ROOT).startsWith("x-"))
1664      return true;
1665
1666    boolean explicitlyUndetermined = "und".equalsIgnoreCase(languageTag) ||
1667        languageTag.toLowerCase(Locale.ROOT).startsWith("und-");
1668
1669    if ("".equals(locale.getLanguage()) && !explicitlyUndetermined)
1670      return false;
1671
1672    return CldrLocaleData.isKnownLanguageTag(languageTag);
1673  }
1674
1675  private static boolean hasJsonExtension(@NonNull String fileName) {
1676    requireNonNull(fileName);
1677    return fileName.toLowerCase(Locale.ROOT).endsWith(JSON_EXTENSION);
1678  }
1679
1680  private static boolean isHiddenFileName(@NonNull String fileName) {
1681    requireNonNull(fileName);
1682    return fileName.startsWith(".");
1683  }
1684
1685  @Nullable
1686  private static String languageTagForFileName(@NonNull String fileName) {
1687    requireNonNull(fileName);
1688
1689    String languageTag = fileName;
1690    boolean hasJsonExtension = hasJsonExtension(fileName);
1691
1692    if (hasJsonExtension)
1693      languageTag = fileName.substring(0, fileName.length() - JSON_EXTENSION.length());
1694
1695    if (isLanguageTag(languageTag))
1696      return languageTag;
1697
1698    if (hasJsonExtension)
1699      throw new LocalizedStringLoadingException(format("File '%s' ends with %s but is not named with a valid IETF BCP 47 language tag. " +
1700          "Use names like 'en', 'en.json', or 'en-US.json'", fileName, JSON_EXTENSION));
1701
1702    return null;
1703  }
1704
1705  @Nullable
1706  private static String languageTagForClasspathFileName(@NonNull String fileName,
1707                                                        @NonNull String source,
1708                                                        @NonNull LocalizedStringWarningHandler warningHandler) {
1709    requireNonNull(fileName);
1710    requireNonNull(source);
1711    requireNonNull(warningHandler);
1712
1713    try {
1714      return languageTagForFileName(fileName);
1715    } catch (LocalizedStringLoadingException e) {
1716      warningHandler.handle(new LocalizedStringWarning(
1717          LocalizedStringWarning.Type.INVALID_CLASSPATH_LOCALE_FILENAME,
1718          source,
1719          format("Ignoring classpath resource '%s': %s", source, e.getMessage())));
1720      return null;
1721    }
1722  }
1723
1724  private static void validateExplicitLocale(@NonNull Locale locale) {
1725    LocaleUtils.requireWellFormed(locale, "Locale key");
1726  }
1727
1728  /**
1729   * Parses out a set of localized strings from the given path.
1730   *
1731   * @param path the path to parse, not null
1732   * @param locale the locale represented by the file, not null
1733   * @return the set of localized strings contained in the file, not null
1734   * @throws LocalizedStringLoadingException if an error occurs while parsing the localized strings file
1735   */
1736  @NonNull
1737  private static Set<@NonNull LocalizedString> parseLocalizedStringsFile(@NonNull Path path, @NonNull Locale locale,
1738                                                                         @NonNull LocalizedStringWarningHandler warningHandler,
1739                                                                         @NonNull LocalizedStringLoadingOptions loadingOptions) {
1740    requireNonNull(path);
1741    requireNonNull(locale);
1742    requireNonNull(warningHandler);
1743    requireNonNull(loadingOptions);
1744
1745    return parseLocalizedStringsFile(path, locale, new LoadingSession(loadingOptions, warningHandler));
1746  }
1747
1748  @NonNull
1749  private static Set<@NonNull LocalizedString> parseLocalizedStringsFile(@NonNull Path path, @NonNull Locale locale,
1750                                                                         @NonNull LoadingSession loadingSession) {
1751    requireNonNull(path);
1752    requireNonNull(locale);
1753    requireNonNull(loadingSession);
1754
1755    String canonicalPath = canonicalPathForPath(path);
1756
1757    if (!Files.isRegularFile(path))
1758      throw new LocalizedStringLoadingException(format("%s is not a regular file", canonicalPath));
1759
1760    try (InputStream inputStream = Files.newInputStream(path)) {
1761      return parse(inputStream, locale, canonicalPath, loadingSession);
1762    } catch (IOException e) {
1763      throw new LocalizedStringLoadingException(format("Unable to load localized strings file contents for %s",
1764          canonicalPath), e);
1765    }
1766  }
1767
1768  @NonNull
1769  private static Set<@NonNull LocalizedString> parse(@NonNull InputStream inputStream, @NonNull Locale locale,
1770                                                     @NonNull String source, @NonNull LoadingSession loadingSession) {
1771    requireNonNull(inputStream);
1772    requireNonNull(locale);
1773    requireNonNull(source);
1774    requireNonNull(loadingSession);
1775
1776    loadingSession.beginLocalizedStringsFile(source);
1777    String contents = readStrictUtf8(inputStream, source, loadingSession.getLoadingOptions(), loadingSession);
1778    return parseLocalizedStrings(source, contents, locale, loadingSession);
1779  }
1780
1781  @NonNull
1782  private static String readStrictUtf8(@NonNull InputStream inputStream, @NonNull String source,
1783                                       @NonNull LocalizedStringLoadingOptions loadingOptions,
1784                                       @NonNull LoadingSession loadingSession) {
1785    requireNonNull(inputStream);
1786    requireNonNull(source);
1787    requireNonNull(loadingOptions);
1788    requireNonNull(loadingSession);
1789
1790    int maximumInputBytes = loadingOptions.getMaximumInputBytes();
1791    byte[] bytes;
1792
1793    try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream(Math.min(maximumInputBytes, 8192))) {
1794      byte[] buffer = new byte[Math.min(maximumInputBytes + 1, 8192)];
1795      int maximumBytesToRead = maximumInputBytes + 1;
1796
1797      while (outputStream.size() < maximumBytesToRead) {
1798        int bytesRead = inputStream.read(buffer, 0,
1799            Math.min(buffer.length, maximumBytesToRead - outputStream.size()));
1800
1801        if (bytesRead == -1)
1802          break;
1803
1804        if (bytesRead == 0) {
1805          int nextByte = inputStream.read();
1806
1807          if (nextByte == -1)
1808            break;
1809
1810          loadingSession.addInputBytes(1, source);
1811          outputStream.write(nextByte);
1812          continue;
1813        }
1814
1815        loadingSession.addInputBytes(bytesRead, source);
1816
1817        outputStream.write(buffer, 0, bytesRead);
1818      }
1819
1820      bytes = outputStream.toByteArray();
1821    } catch (IOException e) {
1822      throw new LocalizedStringLoadingException(format("Unable to load localized strings resource contents for %s", source), e);
1823    }
1824
1825    if (bytes.length > maximumInputBytes)
1826      throw new LocalizedStringLoadingException(format(
1827          "%s: localized strings resource exceeds the maximum size of %d bytes", source, maximumInputBytes));
1828
1829    try {
1830      return normalizeLocalizedStringsFileContents(UTF_8.newDecoder()
1831          .onMalformedInput(CodingErrorAction.REPORT)
1832          .onUnmappableCharacter(CodingErrorAction.REPORT)
1833          .decode(ByteBuffer.wrap(bytes)).toString());
1834    } catch (CharacterCodingException e) {
1835      throw new LocalizedStringLoadingException(format("%s: localized strings resource is not valid UTF-8", source), e);
1836    }
1837  }
1838
1839  @NonNull
1840  private static String readCharacters(@NonNull Reader reader, @NonNull String source,
1841                                       @NonNull LocalizedStringLoadingOptions loadingOptions) {
1842    requireNonNull(reader);
1843    requireNonNull(source);
1844    requireNonNull(loadingOptions);
1845
1846    int maximumCharacters = loadingOptions.getMaximumReaderCharacters();
1847    char[] buffer = new char[Math.min(8192, maximumCharacters)];
1848    StringBuilder contents = new StringBuilder(Math.min(maximumCharacters, 8192));
1849
1850    try {
1851      int charactersRead;
1852
1853      while ((charactersRead = reader.read(buffer)) != -1) {
1854        if (charactersRead == 0) {
1855          int character = reader.read();
1856
1857          if (character == -1)
1858            break;
1859
1860          if (contents.length() == maximumCharacters)
1861            throw new LocalizedStringLoadingException(format(
1862                "%s: localized strings resource exceeds the maximum size of %d characters", source, maximumCharacters));
1863
1864          contents.append((char) character);
1865          continue;
1866        }
1867
1868        if (contents.length() > maximumCharacters - charactersRead)
1869          throw new LocalizedStringLoadingException(format(
1870              "%s: localized strings resource exceeds the maximum size of %d characters", source, maximumCharacters));
1871
1872        contents.append(buffer, 0, charactersRead);
1873      }
1874    } catch (IOException e) {
1875      throw new LocalizedStringLoadingException(format("Unable to load localized strings resource contents for %s", source), e);
1876    }
1877
1878    return normalizeLocalizedStringsFileContents(contents.toString());
1879  }
1880
1881  @NonNull
1882  private static String canonicalPathForPath(@NonNull Path path) {
1883    requireNonNull(path);
1884
1885    try {
1886      return path.toRealPath().toString();
1887    } catch (IOException | SecurityException e) {
1888      throw new LocalizedStringLoadingException(
1889          format("Unable to determine canonical path for localized strings file %s", path), e);
1890    }
1891  }
1892
1893  @NonNull
1894  private static Set<@NonNull LocalizedString> parseLocalizedStrings(@NonNull String canonicalPath,
1895                                                                     @NonNull String localizedStringsFileContents,
1896                                                                     @NonNull Locale locale,
1897                                                                     @NonNull LoadingSession loadingSession) {
1898    requireNonNull(canonicalPath);
1899    requireNonNull(localizedStringsFileContents);
1900    requireNonNull(locale);
1901    requireNonNull(loadingSession);
1902
1903    LocalizedStringLoadingOptions loadingOptions = loadingSession.getLoadingOptions();
1904
1905    if (isJsonWhitespaceOnly(localizedStringsFileContents))
1906      throw new LocalizedStringLoadingException(format(
1907          "%s: a localized strings file may not be blank; use an empty JSON object ({}) for an empty file", canonicalPath));
1908
1909    validateJsonNestingDepth(canonicalPath, localizedStringsFileContents,
1910        loadingOptions.getMaximumJsonNestingDepth());
1911
1912    Set<@NonNull LocalizedString> localizedStrings = new HashSet<>();
1913    JsonValue outerJsonValue;
1914
1915    try {
1916      outerJsonValue = Json.parse(localizedStringsFileContents);
1917    } catch (MinimalJson.ParseException e) {
1918      throw new LocalizedStringLoadingException(format("%s:%d:%d: unable to parse localized strings file",
1919          canonicalPath, e.getLocation().line, e.getLocation().column), e);
1920    }
1921
1922    if (!outerJsonValue.isObject())
1923      throw new LocalizedStringLoadingException(format("%s: a localized strings file must be comprised of a single JSON object", canonicalPath));
1924
1925    JsonObject outerJsonObject = outerJsonValue.asObject();
1926
1927    loadingSession.addTranslationNodes(outerJsonObject.size(), canonicalPath);
1928
1929    Set<String> keys = new HashSet<>();
1930
1931    for (Member member : outerJsonObject) {
1932      String key = member.getName();
1933
1934      if (!keys.add(key))
1935        throw new LocalizedStringLoadingException(format("%s: duplicate localized string key '%s' encountered", canonicalPath, key));
1936
1937      JsonValue value = member.getValue();
1938      validateNoDuplicateObjectMembers(canonicalPath, value, jsonObjectMemberPath("$", key));
1939      LocalizedString localizedString = parseLocalizedString(canonicalPath, key, key, key, value, loadingSession);
1940
1941      try {
1942        LocalizedStringValidator.validate(locale, localizedString);
1943      } catch (IllegalArgumentException e) {
1944        throw new LocalizedStringLoadingException(format(
1945            "%s: semantic validation failed for localized string key '%s'", canonicalPath, key), e);
1946      }
1947
1948      warnOnIncompleteLanguageFormTranslations(canonicalPath, locale, key, localizedString, loadingSession);
1949      localizedStrings.add(localizedString);
1950    }
1951
1952    return Collections.unmodifiableSet(localizedStrings);
1953  }
1954
1955  /**
1956   * Emits a validation warning if a cardinality- or ordinality-driven placeholder omits a language form that its
1957   * locale requires per CLDR (for example, a Russian file that provides {@code CARDINALITY_ONE}/{@code FEW}/
1958   * {@code OTHER} but omits {@code CARDINALITY_MANY}).
1959   * <p>
1960   * This is intentionally a warning rather than a hard failure: a translation whose placeholder can only ever
1961   * receive a subset of values may legitimately supply a subset of forms. Range-driven translations are not checked
1962   * because they are expected to be partial by design. Values that resolve to a
1963   * missing form are treated as resolution failures at runtime according to the configured
1964   * {@link TranslationFailureHandler}, so surfacing the gap here turns a silent runtime degradation into a visible
1965   * validation signal.
1966   *
1967   * @param canonicalPath   the unique path to the file (or URL) being parsed, used for reporting, not null
1968   * @param locale          the locale the file is being loaded for, not null
1969   * @param rootKey         root translation key used to identify warnings for nested alternatives, not null
1970   * @param localizedString the parsed localized string to inspect (recursively, including alternatives), not null
1971   * @param warningHandler  handler to receive any warnings, not null
1972   */
1973  private static void warnOnIncompleteLanguageFormTranslations(@NonNull String canonicalPath,
1974                                                               @NonNull Locale locale,
1975                                                               @NonNull String rootKey,
1976                                                               @NonNull LocalizedString localizedString,
1977                                                               @NonNull LocalizedStringWarningHandler warningHandler) {
1978    requireNonNull(canonicalPath);
1979    requireNonNull(locale);
1980    requireNonNull(rootKey);
1981    requireNonNull(localizedString);
1982    requireNonNull(warningHandler);
1983
1984    for (Map.Entry<@NonNull String, @NonNull PlaceholderDefinition> entry :
1985        localizedString.getPlaceholderDefinitions().entrySet()) {
1986      String placeholderKey = entry.getKey();
1987      PlaceholderDefinition placeholderDefinition = entry.getValue();
1988
1989      if (!(placeholderDefinition instanceof LanguageFormTranslation))
1990        continue;
1991
1992      LanguageFormTranslation languageFormTranslation = (LanguageFormTranslation) placeholderDefinition;
1993
1994      // Range-driven translations legitimately supply a subset of forms; do not check them.
1995      if (languageFormTranslation.getRange().isPresent())
1996        continue;
1997
1998      warnOnIncompleteCardinalityTranslations(canonicalPath, locale, rootKey, placeholderKey,
1999          languageFormTranslation, warningHandler);
2000      warnOnIncompleteOrdinalityTranslations(canonicalPath, locale, rootKey, placeholderKey,
2001          languageFormTranslation, warningHandler);
2002    }
2003
2004    for (LocalizedString alternative : localizedString.getAlternatives())
2005      warnOnIncompleteLanguageFormTranslations(canonicalPath, locale, rootKey, alternative, warningHandler);
2006  }
2007
2008  private static void warnOnIncompleteCardinalityTranslations(@NonNull String canonicalPath,
2009                                                              @NonNull Locale locale,
2010                                                              @NonNull String rootKey,
2011                                                              @NonNull String placeholderKey,
2012                                                              @NonNull LanguageFormTranslation languageFormTranslation,
2013                                                              @NonNull LocalizedStringWarningHandler warningHandler) {
2014    Set<@NonNull Cardinality> providedCardinalities = new TreeSet<>();
2015
2016    for (LanguageForm languageForm : languageFormTranslation.getTranslationsByLanguageForm().keySet())
2017      if (languageForm instanceof Cardinality)
2018        providedCardinalities.add((Cardinality) languageForm);
2019
2020    // An empty set means this placeholder is not cardinality-driven; nothing to check.
2021    if (providedCardinalities.isEmpty())
2022      return;
2023
2024    Set<@NonNull Cardinality> supportedCardinalities = new TreeSet<>(Cardinality.supportedCardinalitiesForLocale(locale));
2025
2026    if (supportedCardinalities.isEmpty())
2027      return;
2028
2029    Set<@NonNull Cardinality> missingCardinalities = new TreeSet<>(supportedCardinalities);
2030    missingCardinalities.removeAll(providedCardinalities);
2031
2032    if (missingCardinalities.isEmpty())
2033      return;
2034
2035    Set<@NonNull String> missingLanguageForms = new LinkedHashSet<>();
2036
2037    for (Cardinality missingCardinality : missingCardinalities)
2038      missingLanguageForms.add(LocalizedStringUtils.localizedStringNameForCardinalityName(missingCardinality.name()));
2039
2040    String message = format("%s: placeholder '%s' for key '%s' is missing %s translation[s] for locale '%s': [%s]. " +
2041            "Supported forms are [%s]. Values that resolve to a missing form are treated as resolution failures at runtime.",
2042        canonicalPath, placeholderKey, rootKey, Cardinality.class.getSimpleName(),
2043        locale.toLanguageTag(), cardinalityNamesFor(missingCardinalities), cardinalityNamesFor(supportedCardinalities));
2044
2045    warningHandler.handle(new LocalizedStringWarning(
2046        LocalizedStringWarning.Type.INCOMPLETE_CARDINALITY_TRANSLATIONS, canonicalPath, locale,
2047        rootKey, placeholderKey, missingLanguageForms, message));
2048  }
2049
2050  private static void warnOnIncompleteOrdinalityTranslations(@NonNull String canonicalPath,
2051                                                             @NonNull Locale locale,
2052                                                             @NonNull String rootKey,
2053                                                             @NonNull String placeholderKey,
2054                                                             @NonNull LanguageFormTranslation languageFormTranslation,
2055                                                             @NonNull LocalizedStringWarningHandler warningHandler) {
2056    Set<@NonNull Ordinality> providedOrdinalities = new TreeSet<>();
2057
2058    for (LanguageForm languageForm : languageFormTranslation.getTranslationsByLanguageForm().keySet())
2059      if (languageForm instanceof Ordinality)
2060        providedOrdinalities.add((Ordinality) languageForm);
2061
2062    // An empty set means this placeholder is not ordinality-driven; nothing to check.
2063    if (providedOrdinalities.isEmpty())
2064      return;
2065
2066    Set<@NonNull Ordinality> supportedOrdinalities = new TreeSet<>(Ordinality.supportedOrdinalitiesForLocale(locale));
2067
2068    if (supportedOrdinalities.isEmpty())
2069      return;
2070
2071    Set<@NonNull Ordinality> missingOrdinalities = new TreeSet<>(supportedOrdinalities);
2072    missingOrdinalities.removeAll(providedOrdinalities);
2073
2074    if (missingOrdinalities.isEmpty())
2075      return;
2076
2077    Set<@NonNull String> missingLanguageForms = new LinkedHashSet<>();
2078
2079    for (Ordinality missingOrdinality : missingOrdinalities)
2080      missingLanguageForms.add(LocalizedStringUtils.localizedStringNameForOrdinalityName(missingOrdinality.name()));
2081
2082    String message = format("%s: placeholder '%s' for key '%s' is missing %s translation[s] for locale '%s': [%s]. " +
2083            "Supported forms are [%s]. Values that resolve to a missing form are treated as resolution failures at runtime.",
2084        canonicalPath, placeholderKey, rootKey, Ordinality.class.getSimpleName(),
2085        locale.toLanguageTag(), ordinalityNamesFor(missingOrdinalities), ordinalityNamesFor(supportedOrdinalities));
2086
2087    warningHandler.handle(new LocalizedStringWarning(
2088        LocalizedStringWarning.Type.INCOMPLETE_ORDINALITY_TRANSLATIONS, canonicalPath, locale,
2089        rootKey, placeholderKey, missingLanguageForms, message));
2090  }
2091
2092  @NonNull
2093  private static String cardinalityNamesFor(@NonNull Set<@NonNull Cardinality> cardinalities) {
2094    requireNonNull(cardinalities);
2095    return cardinalities.stream()
2096        .map(cardinality -> LocalizedStringUtils.localizedStringNameForCardinalityName(cardinality.name()))
2097        .collect(Collectors.joining(", "));
2098  }
2099
2100  @NonNull
2101  private static String ordinalityNamesFor(@NonNull Set<@NonNull Ordinality> ordinalities) {
2102    requireNonNull(ordinalities);
2103    return ordinalities.stream()
2104        .map(ordinality -> LocalizedStringUtils.localizedStringNameForOrdinalityName(ordinality.name()))
2105        .collect(Collectors.joining(", "));
2106  }
2107
2108  /**
2109   * Parses "toplevel" localized string data.
2110   * <p>
2111   * Operates recursively if alternatives are encountered.
2112   *
2113   * @param canonicalPath   the unique path to the file (or URL) being parsed, used for error reporting, not null
2114   * @param rootKey         the root translation key, not null
2115   * @param key             the root translation key or nested alternative expression, not null
2116   * @param declarationPath root key followed by the whole-message alternatives leading to this node, not null
2117   * @param jsonValue       the translation value, which may be a simple string or a complex object, not null
2118   * @param loadingSession  load-wide resource budget, not null
2119   * @return a localized string instance, not null
2120   * @throws LocalizedStringLoadingException if an error occurs while parsing the localized strings file
2121   */
2122  @NonNull
2123  private static LocalizedString parseLocalizedString(@NonNull String canonicalPath, @NonNull String rootKey,
2124                                                      @NonNull String key, @NonNull String declarationPath,
2125                                                      @NonNull JsonValue jsonValue,
2126                                                      @NonNull LoadingSession loadingSession) {
2127    requireNonNull(canonicalPath);
2128    requireNonNull(rootKey);
2129    requireNonNull(key);
2130    requireNonNull(declarationPath);
2131    requireNonNull(jsonValue);
2132    requireNonNull(loadingSession);
2133
2134    LocalizedString.Builder localizedStringBuilder = new LocalizedString.Builder(key);
2135
2136    if (jsonValue.isString()) {
2137      // Simple case - just a key and a value, no translation rules
2138      //
2139      // Example format:
2140      //
2141      // {
2142      //   "Hello, world!" : "Приветствую, мир"
2143      // }
2144
2145      String translation = jsonValue.asString();
2146
2147      if (translation == null)
2148        throw new LocalizedStringLoadingException(format("%s: a translation is required for key '%s'", canonicalPath, key));
2149
2150      validatePlaceholderReferences(canonicalPath, rootKey, translation,
2151          descriptionAtDeclarationPath("translation", rootKey, declarationPath));
2152      return localizedStringBuilder.translation(translation).build();
2153    } else if (jsonValue.isObject()) {
2154      // More complex case, there can be placeholders and alternatives.
2155      //
2156      // Example format:
2157      //
2158      // {
2159      //   "I read {{bookCount}} books" : {
2160      //     "translation" : "I read {{bookCount}} {{books}}",
2161      //     "commentary" : "Message shown when user achieves her book-reading goal for the month",
2162      //     "placeholders" : {
2163      //       "books" : {
2164      //         "value" : "bookCount",
2165      //         "translations" : {
2166      //           "CARDINALITY_ONE" : "book",
2167      //           "CARDINALITY_OTHER" : "books"
2168      //         }
2169      //       }
2170      //     },
2171      //     "alternatives" : [
2172      //       {
2173      //         "bookCount == 0" : {
2174      //           "translation" : "I haven't read any books"
2175      //         }
2176      //       }
2177      //     ]
2178      //   }
2179      // }
2180
2181      JsonObject localizedStringObject = jsonValue.asObject();
2182      validateNoUnexpectedObjectMembers(canonicalPath, key, localizedStringObject, "localized string",
2183          Set.of("translation", "commentary", "placeholders", "alternatives"));
2184
2185      String translation = null;
2186
2187      JsonValue translationJsonValue = localizedStringObject.get("translation");
2188
2189      if (translationJsonValue != null) {
2190        if (!translationJsonValue.isString())
2191          throw new LocalizedStringLoadingException(format("%s: translation must be a string for key '%s'", canonicalPath, key));
2192
2193        translation = translationJsonValue.asString();
2194      }
2195
2196      String commentary = null;
2197
2198      JsonValue commentaryJsonValue = localizedStringObject.get("commentary");
2199
2200      if (commentaryJsonValue != null) {
2201        if (!commentaryJsonValue.isString())
2202          throw new LocalizedStringLoadingException(format("%s: commentary must be a string for key '%s'", canonicalPath, key));
2203
2204        commentary = commentaryJsonValue.asString();
2205      }
2206
2207      Map<@NonNull String, @NonNull PlaceholderDefinition> placeholderDefinitions = new LinkedHashMap<>();
2208
2209      JsonValue placeholdersJsonValue = localizedStringObject.get("placeholders");
2210
2211      if (placeholdersJsonValue != null) {
2212        if (!placeholdersJsonValue.isObject())
2213          throw new LocalizedStringLoadingException(format("%s: the placeholders value must be an object. Key is '%s'", canonicalPath, key));
2214
2215        JsonObject placeholdersJsonObject = placeholdersJsonValue.asObject();
2216
2217        for (Member placeholderMember : placeholdersJsonObject) {
2218          String placeholderKey = placeholderMember.getName();
2219          JsonValue placeholderJsonValue = placeholderMember.getValue();
2220          loadingSession.addTranslationNodes(1, canonicalPath);
2221
2222          ensureValidPlaceholderName(canonicalPath, key, placeholderKey, "placeholder");
2223
2224          if (!placeholderJsonValue.isObject())
2225            throw new LocalizedStringLoadingException(format("%s: the placeholder value must be an object. Key is '%s'", canonicalPath, key));
2226
2227          JsonObject placeholderJsonObject = placeholderJsonValue.asObject();
2228          PlaceholderDefinition placeholderDefinition = parsePlaceholderDefinition(canonicalPath, rootKey,
2229              placeholderKey, declarationPath, placeholderJsonObject, loadingSession);
2230          placeholderDefinitions.put(placeholderKey, placeholderDefinition);
2231        }
2232      }
2233
2234      List<@NonNull LocalizedString> alternatives = new ArrayList<>();
2235
2236      JsonValue alternativesJsonValue = localizedStringObject.get("alternatives");
2237
2238      if (alternativesJsonValue != null) {
2239        if (!alternativesJsonValue.isArray())
2240          throw new LocalizedStringLoadingException(format("%s: alternatives must be an array. Key is '%s'", canonicalPath, key));
2241
2242        JsonArray alternativesJsonArray = alternativesJsonValue.asArray();
2243
2244        if (alternativesJsonArray.isEmpty())
2245          throw new LocalizedStringLoadingException(format("%s: alternatives must contain at least one expression. Key is '%s'",
2246              canonicalPath, key));
2247
2248        for (JsonValue alternativeJsonValue : alternativesJsonArray) {
2249          loadingSession.addTranslationNodes(1, canonicalPath);
2250
2251          if (alternativeJsonValue == null || alternativeJsonValue.isNull())
2252            throw new LocalizedStringLoadingException(format("%s: alternative values cannot be null. Key is '%s'",
2253                canonicalPath, key));
2254
2255          if (!alternativeJsonValue.isObject())
2256            throw new LocalizedStringLoadingException(format("%s: alternative value must be an object. Key is '%s'", canonicalPath, key));
2257
2258          JsonObject outerJsonObject = alternativeJsonValue.asObject();
2259
2260          if (outerJsonObject.isEmpty())
2261            throw new LocalizedStringLoadingException(format("%s: alternative objects must contain at least one expression. Key is '%s'",
2262                canonicalPath, key));
2263
2264          if (outerJsonObject.size() > 1)
2265            throw new LocalizedStringLoadingException(format(
2266                "%s: each alternative object must contain exactly one expression so array order defines first-match precedence. Key is '%s'",
2267                canonicalPath, key));
2268
2269          for (Member member : outerJsonObject) {
2270            String alternativeKey = member.getName();
2271            JsonValue alternativeValue = member.getValue();
2272            validateWholeMessageAlternativeExpression(canonicalPath, rootKey, alternativeKey);
2273            String alternativePath = boundedJsonPath(declarationPath, " -> alternative[", alternativeKey, "]");
2274            alternatives.add(parseLocalizedString(canonicalPath, rootKey, alternativeKey, alternativePath,
2275                alternativeValue, loadingSession));
2276          }
2277        }
2278      }
2279
2280      if (translation == null && alternatives.isEmpty())
2281        throw new LocalizedStringLoadingException(format("%s: either a translation or at least one alternative expression is required for key '%s'",
2282            canonicalPath, key));
2283
2284      if (translation != null)
2285        validatePlaceholderReferences(canonicalPath, rootKey, translation,
2286            descriptionAtDeclarationPath("translation", rootKey, declarationPath));
2287
2288      return localizedStringBuilder.translation(translation)
2289          .commentary(commentary)
2290          .placeholderDefinitions(placeholderDefinitions)
2291          .alternatives(alternatives)
2292          .build();
2293    } else {
2294      throw new LocalizedStringLoadingException(format("%s: either a translation string or object value is required for key '%s'",
2295          canonicalPath, key));
2296    }
2297  }
2298
2299  @NonNull
2300  private static String descriptionAtDeclarationPath(@NonNull String description, @NonNull String rootKey,
2301                                                     @NonNull String declarationPath) {
2302    requireNonNull(description);
2303    requireNonNull(rootKey);
2304    requireNonNull(declarationPath);
2305    return rootKey.equals(declarationPath) ? description : format("%s declared at %s", description, declarationPath);
2306  }
2307
2308  private static void validatePlaceholderReferences(@NonNull String canonicalPath,
2309                                                    @NonNull String rootKey,
2310                                                    @NonNull String translation,
2311                                                    @NonNull String description) {
2312    requireNonNull(canonicalPath);
2313    requireNonNull(rootKey);
2314    requireNonNull(translation);
2315    requireNonNull(description);
2316
2317    Set<@NonNull String> referencedPlaceholderNames;
2318
2319    try {
2320      referencedPlaceholderNames = StringInterpolator.placeholderNamesIn(translation);
2321    } catch (IllegalArgumentException e) {
2322      throw new LocalizedStringLoadingException(format("%s: invalid placeholder reference in %s for key '%s': %s",
2323          canonicalPath, description, rootKey, e.getMessage()), e);
2324    }
2325
2326    for (String placeholderName : referencedPlaceholderNames)
2327      ensureValidPlaceholderName(canonicalPath, rootKey, placeholderName, description + " placeholder reference");
2328  }
2329
2330  private static void validateWholeMessageAlternativeExpression(@NonNull String canonicalPath,
2331                                                                @NonNull String rootKey,
2332                                                                @NonNull String expression) {
2333    requireNonNull(canonicalPath);
2334    requireNonNull(rootKey);
2335    requireNonNull(expression);
2336
2337    try {
2338      EXPRESSION_EVALUATOR.parseAndValidateExpressionTokens(expression);
2339    } catch (ExpressionEvaluationException e) {
2340      throw new LocalizedStringLoadingException(format(
2341          "%s: unable to parse whole-message alternative expression '%s' for root key '%s': %s",
2342          canonicalPath, expression, rootKey, e.getMessage()), e);
2343    }
2344  }
2345
2346  @NonNull
2347  private static PlaceholderDefinition parsePlaceholderDefinition(@NonNull String canonicalPath,
2348                                                                  @NonNull String rootKey,
2349                                                                  @NonNull String placeholderKey,
2350                                                                  @NonNull String declarationPath,
2351                                                                  @NonNull JsonObject placeholderJsonObject,
2352                                                                  @NonNull LoadingSession loadingSession) {
2353    requireNonNull(canonicalPath);
2354    requireNonNull(rootKey);
2355    requireNonNull(placeholderKey);
2356    requireNonNull(declarationPath);
2357    requireNonNull(placeholderJsonObject);
2358    requireNonNull(loadingSession);
2359
2360    validateNoUnexpectedObjectMembers(canonicalPath, rootKey, placeholderJsonObject,
2361        format("placeholder '%s'", placeholderKey),
2362        Set.of("value", "range", "translations", "translation", "alternatives"));
2363
2364    JsonValue valueJsonValue = placeholderJsonObject.get("value");
2365    JsonValue rangeJsonValue = placeholderJsonObject.get("range");
2366    JsonValue translationsJsonValue = placeholderJsonObject.get("translations");
2367    JsonValue translationJsonValue = placeholderJsonObject.get("translation");
2368    JsonValue alternativesJsonValue = placeholderJsonObject.get("alternatives");
2369
2370    boolean hasLanguageFormMember = valueJsonValue != null || rangeJsonValue != null ||
2371        translationsJsonValue != null;
2372    boolean hasTemplateMember = translationJsonValue != null || alternativesJsonValue != null;
2373
2374    if (hasLanguageFormMember && hasTemplateMember)
2375      throw new LocalizedStringLoadingException(format(
2376          "%s: placeholder '%s' for root key '%s' mixes language-form members [value, range, translations] " +
2377              "with template members [translation, alternatives]; placeholder modes are mutually exclusive",
2378          canonicalPath, placeholderKey, rootKey));
2379
2380    if (!hasLanguageFormMember && !hasTemplateMember)
2381      throw new LocalizedStringLoadingException(format(
2382          "%s: placeholder '%s' for root key '%s' must define either a language-form translation " +
2383              "or a template translation", canonicalPath, placeholderKey, rootKey));
2384
2385    if (hasTemplateMember)
2386      return parseExpressionTranslation(canonicalPath, rootKey, placeholderKey, declarationPath, translationJsonValue,
2387          alternativesJsonValue, loadingSession);
2388
2389    return parseLanguageFormTranslation(canonicalPath, rootKey, placeholderKey, declarationPath, valueJsonValue,
2390        rangeJsonValue, translationsJsonValue);
2391  }
2392
2393  @NonNull
2394  private static LanguageFormTranslation parseLanguageFormTranslation(@NonNull String canonicalPath,
2395                                                                      @NonNull String rootKey,
2396                                                                      @NonNull String placeholderKey,
2397                                                                      @NonNull String declarationPath,
2398                                                                      @Nullable JsonValue valueJsonValue,
2399                                                                      @Nullable JsonValue rangeJsonValue,
2400                                                                      @Nullable JsonValue translationsJsonValue) {
2401    requireNonNull(canonicalPath);
2402    requireNonNull(rootKey);
2403    requireNonNull(placeholderKey);
2404    requireNonNull(declarationPath);
2405
2406    rejectExplicitNullPlaceholderMember(canonicalPath, rootKey, placeholderKey, "value", valueJsonValue);
2407    rejectExplicitNullPlaceholderMember(canonicalPath, rootKey, placeholderKey, "range", rangeJsonValue);
2408    rejectExplicitNullPlaceholderMember(canonicalPath, rootKey, placeholderKey, "translations", translationsJsonValue);
2409    boolean hasValue = valueJsonValue != null;
2410    boolean hasRangeValue = rangeJsonValue != null;
2411
2412    if (!hasValue && !hasRangeValue)
2413      throw new LocalizedStringLoadingException(format("%s: a placeholder translation value or range is required. Key is '%s'",
2414          canonicalPath, rootKey));
2415
2416    if (hasValue && hasRangeValue)
2417      throw new LocalizedStringLoadingException(format(
2418          "%s: a placeholder translation cannot have both a value and a range. Key is '%s'",
2419          canonicalPath, rootKey));
2420
2421    return parseSingleAxisLanguageFormTranslation(canonicalPath, rootKey, placeholderKey, declarationPath,
2422        valueJsonValue, rangeJsonValue, translationsJsonValue);
2423  }
2424
2425  @NonNull
2426  private static ExpressionTranslation parseExpressionTranslation(@NonNull String canonicalPath,
2427                                                                  @NonNull String rootKey,
2428                                                                  @NonNull String placeholderKey,
2429                                                                  @NonNull String declarationPath,
2430                                                                  @Nullable JsonValue translationJsonValue,
2431                                                                  @Nullable JsonValue alternativesJsonValue,
2432                                                                  @NonNull LoadingSession loadingSession) {
2433    requireNonNull(canonicalPath);
2434    requireNonNull(rootKey);
2435    requireNonNull(placeholderKey);
2436    requireNonNull(declarationPath);
2437    requireNonNull(loadingSession);
2438
2439    if (translationJsonValue == null)
2440      throw new LocalizedStringLoadingException(format(
2441          "%s: a default template translation is required for placeholder '%s' in root key '%s'",
2442          canonicalPath, placeholderKey, rootKey));
2443
2444    if (translationJsonValue.isNull())
2445      throw new LocalizedStringLoadingException(format(
2446          "%s: default template translation may not be null for placeholder '%s' in root key '%s'",
2447          canonicalPath, placeholderKey, rootKey));
2448
2449    if (!translationJsonValue.isString())
2450      throw new LocalizedStringLoadingException(format(
2451          "%s: default template translation must be a string for placeholder '%s' in root key '%s'",
2452          canonicalPath, placeholderKey, rootKey));
2453
2454    String translation = translationJsonValue.asString();
2455    validatePlaceholderReferences(canonicalPath, rootKey, translation,
2456        descriptionAtDeclarationPath(format("default fragment for generated placeholder '%s'", placeholderKey),
2457            rootKey, declarationPath));
2458
2459    if (alternativesJsonValue == null)
2460      return new ExpressionTranslation(translation);
2461
2462    if (alternativesJsonValue.isNull())
2463      throw new LocalizedStringLoadingException(format(
2464          "%s: fragment alternatives may not be null for placeholder '%s' in root key '%s'",
2465          canonicalPath, placeholderKey, rootKey));
2466
2467    if (!alternativesJsonValue.isArray())
2468      throw new LocalizedStringLoadingException(format(
2469          "%s: fragment alternatives must be an array for placeholder '%s' in root key '%s'",
2470          canonicalPath, placeholderKey, rootKey));
2471
2472    JsonArray alternativesJsonArray = alternativesJsonValue.asArray();
2473
2474    if (alternativesJsonArray.isEmpty())
2475      throw new LocalizedStringLoadingException(format(
2476          "%s: fragment alternatives must contain at least one expression for placeholder '%s' in root key '%s'",
2477          canonicalPath, placeholderKey, rootKey));
2478
2479    List<@NonNull ExpressionAlternative> alternatives = new ArrayList<>(alternativesJsonArray.size());
2480    int alternativeIndex = 0;
2481
2482    for (JsonValue alternativeJsonValue : alternativesJsonArray) {
2483      loadingSession.addTranslationNodes(1, canonicalPath);
2484
2485      if (alternativeJsonValue == null || alternativeJsonValue.isNull())
2486        throw new LocalizedStringLoadingException(format(
2487            "%s: fragment alternative %d may not be null for placeholder '%s' in root key '%s'",
2488            canonicalPath, alternativeIndex, placeholderKey, rootKey));
2489
2490      if (!alternativeJsonValue.isObject())
2491        throw new LocalizedStringLoadingException(format(
2492            "%s: fragment alternative %d must be an object for placeholder '%s' in root key '%s'",
2493            canonicalPath, alternativeIndex, placeholderKey, rootKey));
2494
2495      JsonObject alternativeJsonObject = alternativeJsonValue.asObject();
2496
2497      if (alternativeJsonObject.size() != 1)
2498        throw new LocalizedStringLoadingException(format(
2499            "%s: fragment alternative %d must contain exactly one expression so array order defines " +
2500                "first-match precedence. Placeholder is '%s' in root key '%s'",
2501            canonicalPath, alternativeIndex, placeholderKey, rootKey));
2502
2503      Member alternativeMember = alternativeJsonObject.iterator().next();
2504      String expression = alternativeMember.getName();
2505      JsonValue alternativeTranslationJsonValue = alternativeMember.getValue();
2506
2507      if (!alternativeTranslationJsonValue.isString())
2508        throw new LocalizedStringLoadingException(format(
2509            "%s: fragment alternative %d for expression '%s' must have a string result. " +
2510                "Placeholder is '%s' in root key '%s'",
2511            canonicalPath, alternativeIndex, expression, placeholderKey, rootKey));
2512
2513      String alternativeTranslation = alternativeTranslationJsonValue.asString();
2514      validateFragmentAlternativeExpression(canonicalPath, rootKey, placeholderKey, alternativeIndex, expression);
2515      validatePlaceholderReferences(canonicalPath, rootKey, alternativeTranslation,
2516          descriptionAtDeclarationPath(
2517              format("fragment alternative %d for expression '%s' and generated placeholder '%s'",
2518                  alternativeIndex, expression, placeholderKey), rootKey, declarationPath));
2519      alternatives.add(new ExpressionAlternative(expression, alternativeTranslation));
2520      ++alternativeIndex;
2521    }
2522
2523    return new ExpressionTranslation(translation, alternatives);
2524  }
2525
2526  private static void validateFragmentAlternativeExpression(@NonNull String canonicalPath,
2527                                                            @NonNull String rootKey,
2528                                                            @NonNull String placeholderKey,
2529                                                            int alternativeIndex,
2530                                                            @NonNull String expression) {
2531    requireNonNull(canonicalPath);
2532    requireNonNull(rootKey);
2533    requireNonNull(placeholderKey);
2534    requireNonNull(expression);
2535
2536    try {
2537      EXPRESSION_EVALUATOR.parseAndValidateExpressionTokens(expression);
2538    } catch (ExpressionEvaluationException e) {
2539      throw new LocalizedStringLoadingException(format(
2540          "%s: unable to parse fragment alternative %d expression '%s' for placeholder '%s' in root key '%s': %s",
2541          canonicalPath, alternativeIndex, expression, placeholderKey, rootKey, e.getMessage()), e);
2542    }
2543  }
2544
2545  private static void rejectExplicitNullPlaceholderMember(@NonNull String canonicalPath, @NonNull String key,
2546                                                          @NonNull String placeholderKey, @NonNull String memberName,
2547                                                          @Nullable JsonValue memberValue) {
2548    requireNonNull(canonicalPath);
2549    requireNonNull(key);
2550    requireNonNull(placeholderKey);
2551    requireNonNull(memberName);
2552
2553    if (memberValue != null && memberValue.isNull())
2554      throw new LocalizedStringLoadingException(format(
2555          "%s: placeholder member '%s' may not be null. Placeholder is '%s' for key '%s'",
2556          canonicalPath, memberName, placeholderKey, key));
2557  }
2558
2559  @NonNull
2560  private static LanguageFormTranslation parseSingleAxisLanguageFormTranslation(@NonNull String canonicalPath, @NonNull String key,
2561                                                                                @NonNull String placeholderKey,
2562                                                                                @NonNull String declarationPath,
2563                                                                                @Nullable JsonValue valueJsonValue,
2564                                                                                @Nullable JsonValue rangeJsonValue,
2565                                                                                @Nullable JsonValue translationsJsonValue) {
2566    requireNonNull(canonicalPath);
2567    requireNonNull(key);
2568    requireNonNull(placeholderKey);
2569    requireNonNull(declarationPath);
2570
2571    boolean hasValue = valueJsonValue != null && !valueJsonValue.isNull();
2572    boolean hasRangeValue = rangeJsonValue != null && !rangeJsonValue.isNull();
2573    LanguageFormTranslationRange rangeValue = null;
2574    String value = null;
2575
2576    if (hasRangeValue) {
2577      if (!rangeJsonValue.isObject())
2578        throw new LocalizedStringLoadingException(format("%s: the placeholder translation range must be an object. Key is '%s'", canonicalPath, key));
2579
2580      JsonObject rangeJsonObject = rangeJsonValue.asObject();
2581      validateNoUnexpectedObjectMembers(canonicalPath, key, rangeJsonObject,
2582          format("range for placeholder '%s'", placeholderKey), Set.of("start", "end"));
2583      JsonValue rangeValueStartJsonValue = rangeJsonObject.get("start");
2584      JsonValue rangeValueEndJsonValue = rangeJsonObject.get("end");
2585
2586      if (rangeValueStartJsonValue == null || rangeValueStartJsonValue.isNull())
2587        throw new LocalizedStringLoadingException(format("%s: a placeholder translation range start is required. Key is '%s'", canonicalPath, key));
2588
2589      if (rangeValueEndJsonValue == null || rangeValueEndJsonValue.isNull())
2590        throw new LocalizedStringLoadingException(format("%s: a placeholder translation range end is required. Key is '%s'", canonicalPath, key));
2591
2592      if (!rangeValueStartJsonValue.isString())
2593        throw new LocalizedStringLoadingException(format("%s: a placeholder translation range start must be a string. Key is '%s'", canonicalPath, key));
2594
2595      if (!rangeValueEndJsonValue.isString())
2596        throw new LocalizedStringLoadingException(format("%s: a placeholder translation range end must be a string. Key is '%s'", canonicalPath, key));
2597
2598      String rangeStartValue = rangeValueStartJsonValue.asString();
2599      String rangeEndValue = rangeValueEndJsonValue.asString();
2600
2601      ensureValidPlaceholderName(canonicalPath, key, rangeStartValue, "range start");
2602      ensureValidPlaceholderName(canonicalPath, key, rangeEndValue, "range end");
2603
2604      rangeValue = new LanguageFormTranslationRange(rangeStartValue, rangeEndValue);
2605    } else {
2606      if (!hasValue)
2607        throw new LocalizedStringLoadingException(format("%s: a placeholder translation value or range is required. Key is '%s'", canonicalPath, key));
2608
2609      if (!valueJsonValue.isString())
2610        throw new LocalizedStringLoadingException(format("%s: a placeholder translation value must be a string. Key is '%s'", canonicalPath, key));
2611
2612      value = valueJsonValue.asString();
2613      ensureValidPlaceholderName(canonicalPath, key, value, "placeholder value");
2614    }
2615
2616    if (translationsJsonValue == null || translationsJsonValue.isNull())
2617      throw new LocalizedStringLoadingException(format("%s: placeholder translations are required. Key is '%s'", canonicalPath, key));
2618
2619    if (!translationsJsonValue.isObject())
2620      throw new LocalizedStringLoadingException(format("%s: the placeholder translations value must be an object. Key is '%s'", canonicalPath, key));
2621
2622    Map<@NonNull LanguageForm, @NonNull String> translationsByLanguageForm = new LinkedHashMap<>();
2623
2624    for (Member translationMember : translationsJsonValue.asObject()) {
2625      String languageFormTranslationKey = translationMember.getName();
2626      JsonValue languageFormTranslationJsonValue = translationMember.getValue();
2627      LanguageForm languageForm = SUPPORTED_LANGUAGE_FORMS_BY_NAME.get(languageFormTranslationKey);
2628
2629      if (languageForm == null)
2630        throw new LocalizedStringLoadingException(format("%s: unexpected placeholder translation language form encountered. Key is '%s'. " +
2631                "You provided '%s', valid values are [%s]", canonicalPath, key, languageFormTranslationKey,
2632            SUPPORTED_LANGUAGE_FORMS_BY_NAME.keySet().stream().collect(Collectors.joining(", "))));
2633
2634      if (!languageFormTranslationJsonValue.isString())
2635        throw new LocalizedStringLoadingException(format("%s: the placeholder translation value must be a string. Key is '%s'", canonicalPath, key));
2636
2637      String languageFormTranslation = languageFormTranslationJsonValue.asString();
2638      validatePlaceholderReferences(canonicalPath, key, languageFormTranslation,
2639          descriptionAtDeclarationPath(format("placeholder translation for generated placeholder '%s'", placeholderKey),
2640              key, declarationPath));
2641      translationsByLanguageForm.put(languageForm, languageFormTranslation);
2642    }
2643
2644    if (translationsByLanguageForm.isEmpty())
2645      throw new LocalizedStringLoadingException(format("%s: placeholder translations are required. Key is '%s'", canonicalPath, key));
2646
2647    Set<Class<?>> languageFormTypes = new HashSet<>();
2648
2649    for (LanguageForm languageForm : translationsByLanguageForm.keySet())
2650      languageFormTypes.add(languageForm.getClass());
2651
2652    if (languageFormTypes.size() > 1)
2653      throw new LocalizedStringLoadingException(format("%s: you cannot mix-and-match language forms in placeholder translations. " +
2654          "Placeholder is '%s' for key '%s'", canonicalPath, placeholderKey, key));
2655
2656    if (rangeValue != null) {
2657      boolean hasNonCardinality = translationsByLanguageForm.keySet().stream()
2658          .anyMatch(languageForm -> !(languageForm instanceof Cardinality));
2659
2660      if (hasNonCardinality)
2661        throw new LocalizedStringLoadingException(format("%s: range-based translations only support %s. Placeholder is '%s' for key '%s'",
2662            canonicalPath, Cardinality.class.getSimpleName(), placeholderKey, key));
2663    }
2664
2665    return rangeValue != null
2666        ? new LanguageFormTranslation(rangeValue, translationsByLanguageForm)
2667        : new LanguageFormTranslation(value, translationsByLanguageForm);
2668  }
2669
2670  private static void validateNoUnexpectedObjectMembers(@NonNull String canonicalPath,
2671                                                        @NonNull String key,
2672                                                        @NonNull JsonObject jsonObject,
2673                                                        @NonNull String description,
2674                                                        @NonNull Set<@NonNull String> expectedMemberNames) {
2675    requireNonNull(canonicalPath);
2676    requireNonNull(key);
2677    requireNonNull(jsonObject);
2678    requireNonNull(description);
2679    requireNonNull(expectedMemberNames);
2680
2681    for (Member member : jsonObject)
2682      if (!expectedMemberNames.contains(member.getName()))
2683        throw new LocalizedStringLoadingException(format("%s: unexpected field '%s' in %s for key '%s'. Valid fields are [%s]",
2684            canonicalPath, member.getName(), description, key,
2685            expectedMemberNames.stream().sorted().collect(Collectors.joining(", "))));
2686  }
2687
2688  private static void ensureValidPlaceholderName(@NonNull String canonicalPath, @NonNull String key,
2689                                                 @NonNull String placeholderName, @NonNull String description) {
2690    requireNonNull(canonicalPath);
2691    requireNonNull(key);
2692    requireNonNull(placeholderName);
2693    requireNonNull(description);
2694
2695    if (!LocalizedStringUtils.isValidLocalizedStringIdentifier(placeholderName))
2696      throw new LocalizedStringLoadingException(format("%s: invalid %s '%s'. Placeholder names must start with a Unicode letter or underscore " +
2697          "and contain only Unicode letters, Unicode numbers, Unicode combining marks, underscores, or hyphens. Key is '%s'",
2698          canonicalPath, description, placeholderName, key));
2699
2700    if (SUPPORTED_LANGUAGE_FORMS_BY_NAME.containsKey(placeholderName))
2701      throw new LocalizedStringLoadingException(format("%s: invalid %s '%s'. Placeholder names may not use reserved expression constants. " +
2702          "Key is '%s'", canonicalPath, description, placeholderName, key));
2703  }
2704
2705  @NonNull
2706  private static String normalizeLocalizedStringsFileContents(@NonNull String localizedStringsFileContents) {
2707    requireNonNull(localizedStringsFileContents);
2708
2709    String normalizedLocalizedStringsFileContents = localizedStringsFileContents;
2710
2711    if (!normalizedLocalizedStringsFileContents.isEmpty() && normalizedLocalizedStringsFileContents.charAt(0) == UTF_8_BOM)
2712      normalizedLocalizedStringsFileContents = normalizedLocalizedStringsFileContents.substring(1);
2713
2714    return normalizedLocalizedStringsFileContents;
2715  }
2716
2717  private static boolean isJsonWhitespaceOnly(@NonNull String value) {
2718    requireNonNull(value);
2719
2720    for (int i = 0; i < value.length(); i++) {
2721      char character = value.charAt(i);
2722
2723      if (character != ' ' && character != '\t' && character != '\n' && character != '\r')
2724        return false;
2725    }
2726
2727    return true;
2728  }
2729
2730  private static void validateJsonNestingDepth(@NonNull String source, @NonNull String json,
2731                                               int maximumJsonNestingDepth) {
2732    requireNonNull(source);
2733    requireNonNull(json);
2734
2735    int depth = 0;
2736    boolean insideString = false;
2737    boolean escaped = false;
2738
2739    for (int i = 0; i < json.length(); i++) {
2740      char character = json.charAt(i);
2741
2742      if (insideString) {
2743        if (escaped) {
2744          escaped = false;
2745        } else if (character == '\\') {
2746          escaped = true;
2747        } else if (character == '"') {
2748          insideString = false;
2749        }
2750
2751        continue;
2752      }
2753
2754      if (character == '"') {
2755        insideString = true;
2756      } else if (character == '{' || character == '[') {
2757        ++depth;
2758
2759        if (depth > maximumJsonNestingDepth)
2760          throw new LocalizedStringLoadingException(format(
2761              "%s: JSON nesting depth exceeds the maximum of %d", source, maximumJsonNestingDepth));
2762      } else if (character == '}' || character == ']') {
2763        --depth;
2764      }
2765    }
2766  }
2767
2768  private static void validateNoDuplicateObjectMembers(@NonNull String canonicalPath, @NonNull JsonValue jsonValue,
2769                                                       @NonNull String jsonPath) {
2770    requireNonNull(canonicalPath);
2771    requireNonNull(jsonValue);
2772    requireNonNull(jsonPath);
2773
2774    if (jsonValue.isObject()) {
2775      Set<@NonNull String> memberNames = new LinkedHashSet<>();
2776
2777      for (Member member : jsonValue.asObject()) {
2778        String memberName = member.getName();
2779
2780        if (!memberNames.add(memberName))
2781          throw new LocalizedStringLoadingException(format("%s: duplicate JSON object member '%s' encountered at %s",
2782              canonicalPath, boundedDiagnosticValue(memberName), jsonPath));
2783
2784        validateNoDuplicateObjectMembers(canonicalPath, member.getValue(),
2785            jsonObjectMemberPath(jsonPath, memberName));
2786      }
2787    } else if (jsonValue.isArray()) {
2788      int index = 0;
2789
2790      for (JsonValue arrayElementJsonValue : jsonValue.asArray()) {
2791        if (arrayElementJsonValue != null && !arrayElementJsonValue.isNull())
2792          validateNoDuplicateObjectMembers(canonicalPath, arrayElementJsonValue,
2793              jsonArrayElementPath(jsonPath, index));
2794
2795        ++index;
2796      }
2797    }
2798  }
2799
2800  @NonNull
2801  private static String jsonObjectMemberPath(@NonNull String parentPath, @NonNull String memberName) {
2802    requireNonNull(parentPath);
2803    requireNonNull(memberName);
2804    return boundedJsonPath(parentPath, ".", memberName, "");
2805  }
2806
2807  @NonNull
2808  private static String jsonArrayElementPath(@NonNull String parentPath, int index) {
2809    requireNonNull(parentPath);
2810    return boundedJsonPath(parentPath, "[", Integer.toString(index), "]");
2811  }
2812
2813  @NonNull
2814  private static String boundedJsonPath(@NonNull String parentPath, @NonNull String prefix,
2815                                        @NonNull String component, @NonNull String suffix) {
2816    requireNonNull(parentPath);
2817    requireNonNull(prefix);
2818    requireNonNull(component);
2819    requireNonNull(suffix);
2820
2821    if (parentPath.length() >= MAXIMUM_JSON_DIAGNOSTIC_PATH_CHARACTERS)
2822      return parentPath;
2823
2824    StringBuilder path = new StringBuilder(Math.min(MAXIMUM_JSON_DIAGNOSTIC_PATH_CHARACTERS,
2825        parentPath.length() + prefix.length() + Math.min(component.length(), 64) + suffix.length()));
2826    appendBoundedPathPart(path, parentPath);
2827    appendBoundedPathPart(path, prefix);
2828    appendBoundedPathPart(path, component);
2829    appendBoundedPathPart(path, suffix);
2830    return path.toString();
2831  }
2832
2833  private static void appendBoundedPathPart(@NonNull StringBuilder path, @NonNull String part) {
2834    requireNonNull(path);
2835    requireNonNull(part);
2836
2837    int remaining = MAXIMUM_JSON_DIAGNOSTIC_PATH_CHARACTERS - path.length();
2838
2839    if (remaining <= 0)
2840      return;
2841
2842    if (part.length() <= remaining) {
2843      path.append(part);
2844      return;
2845    }
2846
2847    if (remaining > 1)
2848      path.append(part, 0, remaining - 1);
2849
2850    path.append('\u2026');
2851  }
2852
2853  @NonNull
2854  private static String boundedDiagnosticValue(@NonNull String value) {
2855    requireNonNull(value);
2856
2857    if (value.length() <= 256)
2858      return value;
2859
2860    return value.substring(0, 255) + '\u2026';
2861  }
2862
2863  @NotThreadSafe
2864  private static final class LoadingSession implements LocalizedStringWarningHandler {
2865    @NonNull
2866    private final LocalizedStringLoadingOptions loadingOptions;
2867    @NonNull
2868    private final LocalizedStringWarningHandler warningHandler;
2869    private long inputBytes;
2870    private int localizedStringsFiles;
2871    private int translationNodes;
2872    private int warnings;
2873    private int discoveryEntries;
2874
2875    private LoadingSession(@NonNull LocalizedStringLoadingOptions loadingOptions,
2876                           @NonNull LocalizedStringWarningHandler warningHandler) {
2877      this.loadingOptions = requireNonNull(loadingOptions);
2878      this.warningHandler = requireNonNull(warningHandler);
2879    }
2880
2881    @NonNull
2882    private LocalizedStringLoadingOptions getLoadingOptions() {
2883      return loadingOptions;
2884    }
2885
2886    private void beginLocalizedStringsFile(@NonNull String source) {
2887      requireNonNull(source);
2888
2889      if (localizedStringsFiles >= loadingOptions.getMaximumLocalizedStringsFiles())
2890        throw new LocalizedStringLoadingException(format(
2891            "%s: localized strings load exceeds the aggregate localized strings file limit of %d", source,
2892            loadingOptions.getMaximumLocalizedStringsFiles()));
2893
2894      ++localizedStringsFiles;
2895    }
2896
2897    private void addInputBytes(int bytes, @NonNull String source) {
2898      requireNonNull(source);
2899
2900      long maximumInputBytes = loadingOptions.getMaximumTotalInputBytes();
2901
2902      if (bytes < 0 || inputBytes > maximumInputBytes - bytes)
2903        throw new LocalizedStringLoadingException(format(
2904            "%s: localized strings load exceeds the aggregate maximum of %d input bytes", source,
2905            maximumInputBytes));
2906
2907      inputBytes += bytes;
2908    }
2909
2910    private void addTranslationNodes(int translationNodeCount, @NonNull String source) {
2911      requireNonNull(source);
2912
2913      int maximumTranslationNodes = loadingOptions.getMaximumTranslationNodes();
2914
2915      if (translationNodeCount < 0 || translationNodes > maximumTranslationNodes - translationNodeCount)
2916        throw new LocalizedStringLoadingException(format(
2917            "%s: localized strings load exceeds the aggregate maximum of %d translation nodes", source,
2918            maximumTranslationNodes));
2919
2920      translationNodes += translationNodeCount;
2921    }
2922
2923    private void discoverEntry(@NonNull String source) {
2924      requireNonNull(source);
2925
2926      if (discoveryEntries >= loadingOptions.getMaximumDiscoveryEntries())
2927        throw new LocalizedStringLoadingException(format(
2928            "%s: localized strings load exceeds the aggregate maximum of %d discovery entries", source,
2929            loadingOptions.getMaximumDiscoveryEntries()));
2930
2931      ++discoveryEntries;
2932    }
2933
2934    @Override
2935    public void handle(@NonNull LocalizedStringWarning warning) {
2936      requireNonNull(warning);
2937
2938      if (warnings >= loadingOptions.getMaximumWarnings())
2939        throw new LocalizedStringLoadingException(format(
2940            "%s: localized strings load exceeds the aggregate maximum of %d warnings", warning.getSource(),
2941            loadingOptions.getMaximumWarnings()));
2942
2943      ++warnings;
2944      warningHandler.handle(warning);
2945    }
2946  }
2947
2948  @NotThreadSafe
2949  private static final class EffectiveJarEntries {
2950    @NonNull
2951    private final Map<@NonNull String, @NonNull JarEntry> entriesByRelativeName;
2952    private final boolean packagePresent;
2953
2954    private EffectiveJarEntries(@NonNull Map<@NonNull String, @NonNull JarEntry> entriesByRelativeName,
2955                                boolean packagePresent) {
2956      this.entriesByRelativeName = requireNonNull(entriesByRelativeName);
2957      this.packagePresent = packagePresent;
2958    }
2959
2960    @NonNull
2961    private Map<@NonNull String, @NonNull JarEntry> getEntriesByRelativeName() {
2962      return entriesByRelativeName;
2963    }
2964
2965    private boolean isPackagePresent() {
2966      return packagePresent;
2967    }
2968  }
2969
2970  @NotThreadSafe
2971  private static final class JarPackageLoadResult {
2972    @NonNull
2973    private final Map<@NonNull Locale, @NonNull Set<@NonNull SourceLocalizedString>> localizedStringsByLocale;
2974    private final boolean packagePresent;
2975
2976    private JarPackageLoadResult(
2977        @NonNull Map<@NonNull Locale, @NonNull Set<@NonNull SourceLocalizedString>> localizedStringsByLocale,
2978        boolean packagePresent) {
2979      this.localizedStringsByLocale = requireNonNull(localizedStringsByLocale);
2980      this.packagePresent = packagePresent;
2981    }
2982
2983    @NonNull
2984    private Map<@NonNull Locale, @NonNull Set<@NonNull SourceLocalizedString>> getLocalizedStringsByLocale() {
2985      return localizedStringsByLocale;
2986    }
2987
2988    private boolean isPackagePresent() {
2989      return packagePresent;
2990    }
2991  }
2992
2993  private static final class JarEntrySelection {
2994    @NonNull
2995    private final JarEntry jarEntry;
2996    private final int version;
2997
2998    private JarEntrySelection(@NonNull JarEntry jarEntry, int version) {
2999      this.jarEntry = requireNonNull(jarEntry);
3000      this.version = version;
3001    }
3002
3003    @NonNull
3004    private JarEntry getJarEntry() {
3005      return jarEntry;
3006    }
3007
3008    private int getVersion() {
3009      return version;
3010    }
3011  }
3012
3013  private static final class SourceLocalizedString {
3014    @NonNull
3015    private final LocalizedString localizedString;
3016    @NonNull
3017    private final String origin;
3018
3019    private SourceLocalizedString(@NonNull LocalizedString localizedString, @NonNull String origin) {
3020      requireNonNull(localizedString);
3021      requireNonNull(origin);
3022
3023      this.localizedString = localizedString;
3024      this.origin = origin;
3025    }
3026
3027    @NonNull
3028    private LocalizedString getLocalizedString() {
3029      return localizedString;
3030    }
3031
3032    @NonNull
3033    private String getOrigin() {
3034      return origin;
3035    }
3036  }
3037}