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}