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