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 org.jspecify.annotations.NonNull; 020import org.jspecify.annotations.Nullable; 021 022import javax.annotation.concurrent.Immutable; 023import javax.annotation.concurrent.NotThreadSafe; 024import java.util.ArrayDeque; 025import java.util.ArrayList; 026import java.util.Collections; 027import java.util.Deque; 028import java.util.IdentityHashMap; 029import java.util.LinkedHashMap; 030import java.util.List; 031import java.util.Map; 032import java.util.Objects; 033import java.util.Optional; 034 035import static com.lokalized.Diagnostics.format; 036import static java.util.Objects.requireNonNull; 037 038/** 039 * Represents a single localized string - its key, translated value, and any associated translation rules. 040 * <p> 041 * Normally instances are sourced from a file which contains all localized strings for a given locale. 042 * <p> 043 * Unicode letter, number, and mark membership in placeholder and expression identifiers follows the executing JDK's 044 * {@link java.util.regex.Pattern} Unicode tables. For cross-JDK portability, author identifiers for the oldest JDK in 045 * the deployment fleet; {@code [A-Za-z_][A-Za-z0-9_-]*} is the portable ASCII subset. 046 * 047 * @author <a href="https://revetkn.com">Mark Allen</a> 048 */ 049@Immutable 050public final class LocalizedString { 051 private static final int MAXIMUM_DIAGNOSTIC_NODES = 256; 052 private static final int MAXIMUM_DIAGNOSTIC_CHARACTERS = 16 * 1024; 053 @NonNull 054 private static final String DIAGNOSTIC_TRUNCATION_MARKER = "<truncated>"; 055 056 @NonNull 057 private final String key; 058 @Nullable 059 private final String translation; 060 @Nullable 061 private final String commentary; 062 @NonNull 063 private final Map<@NonNull String, @NonNull PlaceholderDefinition> placeholderDefinitions; 064 @NonNull 065 private final List<@NonNull LocalizedString> alternatives; 066 067 /** 068 * Constructs a localized string with a key, default translation, and additional translation rules. 069 * 070 * @param key this string's translation key, not null 071 * @param translation this string's default translation, may be null 072 * @param commentary this string's commentary (usage/translation notes), may be null 073 * @param placeholderDefinitions generated-placeholder definitions, may be null 074 * @param alternatives alternative expression-driven translations for this string, may be null 075 */ 076 private LocalizedString(@NonNull String key, @Nullable String translation, @Nullable String commentary, 077 @Nullable Map<@NonNull String, ? extends @NonNull PlaceholderDefinition> placeholderDefinitions, 078 @Nullable List<@NonNull LocalizedString> alternatives) { 079 requireNonNull(key); 080 081 this.key = key; 082 this.translation = translation; 083 this.commentary = commentary; 084 085 if (placeholderDefinitions == null) { 086 this.placeholderDefinitions = Collections.emptyMap(); 087 } else { 088 Map<@NonNull String, @NonNull PlaceholderDefinition> placeholderDefinitionsCopy = new LinkedHashMap<>(); 089 090 for (Map.Entry<@NonNull String, ? extends @NonNull PlaceholderDefinition> entry 091 : placeholderDefinitions.entrySet()) 092 placeholderDefinitionsCopy.put( 093 requireNonNull(entry.getKey(), "placeholderDefinitions must not contain a null key"), 094 requireNonNull(entry.getValue(), "placeholderDefinitions must not contain a null value")); 095 096 this.placeholderDefinitions = Collections.unmodifiableMap(placeholderDefinitionsCopy); 097 } 098 099 if (alternatives == null) { 100 this.alternatives = Collections.emptyList(); 101 } else { 102 List<@NonNull LocalizedString> alternativesCopy = new ArrayList<>(alternatives.size()); 103 104 for (LocalizedString alternative : alternatives) 105 alternativesCopy.add(requireNonNull(alternative, "alternatives must not contain null elements")); 106 107 this.alternatives = Collections.unmodifiableList(alternativesCopy); 108 } 109 110 if (translation == null && this.alternatives.isEmpty()) 111 throw new IllegalArgumentException(format("You must provide either a translation or at least one alternative expression. " + 112 "Offending key was '%s'", key)); 113 } 114 115 /** 116 * Generates a bounded diagnostic {@code String} representation of this object. 117 * <p> 118 * Deep, shared, cyclic, or oversized values may be abbreviated with reference, cycle, or truncation markers. 119 * 120 * @return a string representation of this object, not null 121 */ 122 @Override 123 @NonNull 124 public String toString() { 125 DiagnosticRenderer renderer = new DiagnosticRenderer(); 126 return renderer.render(this); 127 } 128 129 /** 130 * Checks if this object is equal to another one. 131 * 132 * @param other the object to check, null returns false 133 * @return true if this is equal to the other object, false otherwise 134 */ 135 @Override 136 public boolean equals(@Nullable Object other) { 137 if (this == other) 138 return true; 139 140 if (other == null || !getClass().equals(other.getClass())) 141 return false; 142 143 LocalizedString localizedString = (LocalizedString) other; 144 Deque<LocalizedStringPair> pending = new ArrayDeque<>(); 145 IdentityHashMap<LocalizedString, IdentityHashMap<LocalizedString, Boolean>> compared = new IdentityHashMap<>(); 146 pending.push(new LocalizedStringPair(this, localizedString)); 147 148 while (!pending.isEmpty()) { 149 LocalizedStringPair pair = pending.pop(); 150 LocalizedString left = pair.left; 151 LocalizedString right = pair.right; 152 153 if (left == right) 154 continue; 155 156 IdentityHashMap<LocalizedString, Boolean> comparedRights = compared.get(left); 157 if (comparedRights == null) { 158 comparedRights = new IdentityHashMap<>(); 159 compared.put(left, comparedRights); 160 } else if (comparedRights.containsKey(right)) { 161 continue; 162 } 163 comparedRights.put(right, Boolean.TRUE); 164 165 if (!Objects.equals(left.key, right.key) 166 || !Objects.equals(left.translation, right.translation) 167 || !Objects.equals(left.commentary, right.commentary) 168 || !Objects.equals(left.placeholderDefinitions, right.placeholderDefinitions) 169 || left.alternatives.size() != right.alternatives.size()) 170 return false; 171 172 for (int index = 0; index < left.alternatives.size(); ++index) { 173 LocalizedString leftAlternative = left.alternatives.get(index); 174 LocalizedString rightAlternative = right.alternatives.get(index); 175 176 if (leftAlternative == rightAlternative) 177 continue; 178 if (leftAlternative == null || rightAlternative == null) 179 return false; 180 181 pending.push(new LocalizedStringPair(leftAlternative, rightAlternative)); 182 } 183 } 184 185 return true; 186 } 187 188 /** 189 * A hash code for this object. 190 * 191 * @return a suitable hash code 192 */ 193 @Override 194 public int hashCode() { 195 IdentityHashMap<LocalizedString, Integer> computedHashes = new IdentityHashMap<>(); 196 IdentityHashMap<LocalizedString, Boolean> active = new IdentityHashMap<>(); 197 Deque<LocalizedStringHashFrame> pending = new ArrayDeque<>(); 198 active.put(this, Boolean.TRUE); 199 pending.push(new LocalizedStringHashFrame(this)); 200 201 while (!pending.isEmpty()) { 202 LocalizedStringHashFrame frame = pending.peek(); 203 204 if (frame.nextAlternativeIndex < frame.localizedString.alternatives.size()) { 205 LocalizedString alternative = frame.localizedString.alternatives.get(frame.nextAlternativeIndex++); 206 207 if (alternative == null) 208 continue; 209 if (computedHashes.containsKey(alternative)) 210 continue; 211 if (active.containsKey(alternative)) 212 throw new IllegalStateException("Localized string alternative graph contains an identity cycle"); 213 214 active.put(alternative, Boolean.TRUE); 215 pending.push(new LocalizedStringHashFrame(alternative)); 216 continue; 217 } 218 219 int alternativesHash = 1; 220 for (LocalizedString alternative : frame.localizedString.alternatives) 221 alternativesHash = 31 * alternativesHash + (alternative == null ? 0 : computedHashes.get(alternative)); 222 223 int hash = 1; 224 hash = 31 * hash + Objects.hashCode(frame.localizedString.key); 225 hash = 31 * hash + Objects.hashCode(frame.localizedString.translation); 226 hash = 31 * hash + Objects.hashCode(frame.localizedString.commentary); 227 hash = 31 * hash + Objects.hashCode(frame.localizedString.placeholderDefinitions); 228 hash = 31 * hash + alternativesHash; 229 computedHashes.put(frame.localizedString, hash); 230 active.remove(frame.localizedString); 231 pending.pop(); 232 } 233 234 return computedHashes.get(this); 235 } 236 237 private static final class LocalizedStringPair { 238 @NonNull private final LocalizedString left; 239 @NonNull private final LocalizedString right; 240 241 private LocalizedStringPair(@NonNull LocalizedString left, @NonNull LocalizedString right) { 242 this.left = requireNonNull(left); 243 this.right = requireNonNull(right); 244 } 245 } 246 247 private static final class LocalizedStringHashFrame { 248 @NonNull private final LocalizedString localizedString; 249 private int nextAlternativeIndex; 250 251 private LocalizedStringHashFrame(@NonNull LocalizedString localizedString) { 252 this.localizedString = requireNonNull(localizedString); 253 } 254 } 255 256 @NotThreadSafe 257 private static final class DiagnosticRenderer { 258 @NonNull private final BoundedDiagnosticOutput output = new BoundedDiagnosticOutput(); 259 @NonNull private final IdentityHashMap<@NonNull LocalizedString, @NonNull DiagnosticNodeState> states = new IdentityHashMap<>(); 260 @NonNull private final Deque<@NonNull LocalizedStringDiagnosticFrame> pending = new ArrayDeque<>(); 261 262 @NonNull 263 private String render(@NonNull LocalizedString localizedString) { 264 DiagnosticNodeState rootState = new DiagnosticNodeState(1); 265 states.put(localizedString, rootState); 266 pending.push(new LocalizedStringDiagnosticFrame(localizedString, rootState)); 267 268 while (!pending.isEmpty() && !output.isTruncated()) { 269 LocalizedStringDiagnosticFrame frame = pending.peek(); 270 271 if (!frame.headerRendered) { 272 renderHeader(frame.localizedString); 273 frame.headerRendered = true; 274 } 275 276 if (output.isTruncated()) 277 break; 278 279 if (frame.nextAlternativeIndex < frame.localizedString.alternatives.size()) { 280 if (frame.nextAlternativeIndex > 0) 281 output.append(", "); 282 283 @Nullable LocalizedString alternative = frame.localizedString.alternatives.get(frame.nextAlternativeIndex++); 284 285 if (alternative == null) { 286 output.append("null"); 287 continue; 288 } 289 290 @Nullable DiagnosticNodeState existingState = states.get(alternative); 291 292 if (existingState != null) { 293 output.append(existingState.active ? "<cycle#" : "<ref#"); 294 output.append(Integer.toString(existingState.identifier)); 295 output.append(">"); 296 continue; 297 } 298 299 if (states.size() >= MAXIMUM_DIAGNOSTIC_NODES) { 300 output.truncate(); 301 break; 302 } 303 304 DiagnosticNodeState alternativeState = new DiagnosticNodeState(states.size() + 1); 305 states.put(alternative, alternativeState); 306 pending.push(new LocalizedStringDiagnosticFrame(alternative, alternativeState)); 307 continue; 308 } 309 310 if (!frame.localizedString.alternatives.isEmpty()) 311 output.append("]"); 312 output.append("}"); 313 frame.state.active = false; 314 pending.pop(); 315 } 316 317 return output.toString(); 318 } 319 320 private void renderHeader(@NonNull LocalizedString localizedString) { 321 output.append("LocalizedString{key="); 322 output.append(localizedString.key); 323 324 if (localizedString.translation != null) { 325 output.append(", translation="); 326 output.append(localizedString.translation); 327 } 328 329 if (localizedString.commentary != null) { 330 output.append(", commentary="); 331 output.append(localizedString.commentary); 332 } 333 334 if (!localizedString.placeholderDefinitions.isEmpty()) { 335 output.append(", placeholderDefinitions="); 336 renderPlaceholderDefinitions(localizedString.placeholderDefinitions); 337 } 338 339 if (!localizedString.alternatives.isEmpty()) 340 output.append(", alternatives=["); 341 } 342 343 private void renderPlaceholderDefinitions( 344 @NonNull Map<@NonNull String, @NonNull PlaceholderDefinition> placeholderDefinitions) { 345 output.append("{"); 346 int index = 0; 347 348 for (Map.Entry<@NonNull String, @NonNull PlaceholderDefinition> entry : placeholderDefinitions.entrySet()) { 349 if (index++ > 0) 350 output.append(", "); 351 output.append(entry.getKey()); 352 output.append("="); 353 renderPlaceholderDefinition(entry.getValue()); 354 355 if (output.isTruncated()) 356 return; 357 } 358 359 output.append("}"); 360 } 361 362 private void renderPlaceholderDefinition(@Nullable PlaceholderDefinition placeholderDefinition) { 363 if (placeholderDefinition == null) { 364 output.append("null"); 365 } else if (placeholderDefinition instanceof LanguageFormTranslation) { 366 renderLanguageFormTranslation((LanguageFormTranslation) placeholderDefinition); 367 } else if (placeholderDefinition instanceof ExpressionTranslation) { 368 renderExpressionTranslation((ExpressionTranslation) placeholderDefinition); 369 } else { 370 output.append("<unknown-placeholder-definition>"); 371 } 372 } 373 374 private void renderLanguageFormTranslation(@NonNull LanguageFormTranslation languageFormTranslation) { 375 output.append("LanguageFormTranslation{"); 376 377 if (languageFormTranslation.range != null) { 378 output.append("range=LanguageFormTranslationRange{start="); 379 output.append(languageFormTranslation.range.start); 380 output.append(", end="); 381 output.append(languageFormTranslation.range.end); 382 output.append("}"); 383 } else { 384 output.append("value="); 385 output.append(languageFormTranslation.value); 386 } 387 388 output.append(", translationsByLanguageForm={"); 389 int index = 0; 390 391 for (Map.Entry<@NonNull LanguageForm, @NonNull String> entry 392 : languageFormTranslation.translationsByLanguageForm.entrySet()) { 393 if (index++ > 0) 394 output.append(", "); 395 renderLanguageForm(entry.getKey()); 396 output.append("="); 397 output.append(entry.getValue()); 398 399 if (output.isTruncated()) 400 return; 401 } 402 403 output.append("}}"); 404 } 405 406 private void renderLanguageForm(@Nullable LanguageForm languageForm) { 407 if (languageForm == null) { 408 output.append("null"); 409 } else if (languageForm instanceof Enum<?>) { 410 output.append(((Enum<?>) languageForm).name()); 411 } else { 412 output.append("<unsupported-language-form>"); 413 } 414 } 415 416 private void renderExpressionTranslation(@NonNull ExpressionTranslation expressionTranslation) { 417 output.append("ExpressionTranslation{translation="); 418 output.append(expressionTranslation.translation); 419 output.append(", alternatives=["); 420 421 for (int index = 0; index < expressionTranslation.alternatives.size(); ++index) { 422 if (index > 0) 423 output.append(", "); 424 425 @Nullable ExpressionAlternative alternative = expressionTranslation.alternatives.get(index); 426 427 if (alternative == null) { 428 output.append("null"); 429 } else { 430 output.append("ExpressionAlternative{expression="); 431 output.append(alternative.expression); 432 output.append(", translation="); 433 output.append(alternative.translation); 434 output.append("}"); 435 } 436 437 if (output.isTruncated()) 438 return; 439 } 440 441 output.append("]}"); 442 } 443 } 444 445 @NotThreadSafe 446 private static final class DiagnosticNodeState { 447 private final int identifier; 448 private boolean active = true; 449 450 private DiagnosticNodeState(int identifier) { 451 this.identifier = identifier; 452 } 453 } 454 455 @NotThreadSafe 456 private static final class LocalizedStringDiagnosticFrame { 457 @NonNull private final LocalizedString localizedString; 458 @NonNull private final DiagnosticNodeState state; 459 private boolean headerRendered; 460 private int nextAlternativeIndex; 461 462 private LocalizedStringDiagnosticFrame(@NonNull LocalizedString localizedString, 463 @NonNull DiagnosticNodeState state) { 464 this.localizedString = requireNonNull(localizedString); 465 this.state = requireNonNull(state); 466 } 467 } 468 469 @NotThreadSafe 470 private static final class BoundedDiagnosticOutput { 471 @NonNull private final StringBuilder stringBuilder = new StringBuilder(); 472 private boolean truncated; 473 474 private void append(@Nullable String value) { 475 if (truncated) 476 return; 477 478 String renderedValue = value == null ? "null" : value; 479 int availableCharacters = MAXIMUM_DIAGNOSTIC_CHARACTERS - stringBuilder.length(); 480 481 if (renderedValue.length() <= availableCharacters) { 482 stringBuilder.append(renderedValue); 483 return; 484 } 485 486 if (availableCharacters > 0) 487 stringBuilder.append(renderedValue, 0, availableCharacters); 488 truncate(); 489 } 490 491 private void truncate() { 492 if (truncated) 493 return; 494 495 int markerStart = MAXIMUM_DIAGNOSTIC_CHARACTERS - DIAGNOSTIC_TRUNCATION_MARKER.length(); 496 497 if (stringBuilder.length() > markerStart) { 498 int safeMarkerStart = markerStart; 499 500 if (safeMarkerStart > 0 && Character.isHighSurrogate(stringBuilder.charAt(safeMarkerStart - 1)) 501 && Character.isLowSurrogate(stringBuilder.charAt(safeMarkerStart))) 502 --safeMarkerStart; 503 504 stringBuilder.setLength(safeMarkerStart); 505 } 506 stringBuilder.append(DIAGNOSTIC_TRUNCATION_MARKER); 507 truncated = true; 508 } 509 510 private boolean isTruncated() { 511 return truncated; 512 } 513 514 @Override 515 @NonNull 516 public String toString() { 517 return stringBuilder.toString(); 518 } 519 } 520 521 /** 522 * Gets this string's translation key. 523 * 524 * @return this string's translation key, not null 525 */ 526 @NonNull 527 public String getKey() { 528 return key; 529 } 530 531 /** 532 * Gets this string's default translation, if available. 533 * 534 * @return this string's default translation, not null 535 */ 536 @NonNull 537 public Optional<@NonNull String> getTranslation() { 538 return Optional.ofNullable(translation); 539 } 540 541 /** 542 * Gets this string's commentary (usage/translation notes). 543 * 544 * @return this string's commentary, not null 545 */ 546 @NonNull 547 public Optional<@NonNull String> getCommentary() { 548 return Optional.ofNullable(commentary); 549 } 550 551 /** 552 * Gets the generated-placeholder definitions declared by this localized string. 553 * <p> 554 * Current concrete types are {@link LanguageFormTranslation} and {@link ExpressionTranslation}. Consumers should 555 * inspect the definition type with {@code instanceof} before accessing subtype-specific state and handle unfamiliar 556 * future library-defined types defensively. 557 * 558 * @return generated-placeholder definitions keyed by placeholder name, not null 559 * @since 3.0.0 560 */ 561 @NonNull 562 public Map<@NonNull String, @NonNull PlaceholderDefinition> getPlaceholderDefinitions() { 563 return placeholderDefinitions; 564 } 565 566 /** 567 * Gets alternative expression-driven translations for this string. 568 * <p> 569 * In this context, the {@code key} for each alternative is a localization expression, not a translation key. 570 * <p> 571 * For example, if {@code bookCount == 0} you might want to say {@code I haven't read any books} instead of {@code I read 0 books}. 572 * 573 * @return alternative expression-driven translations for this string, not null 574 */ 575 @NonNull 576 public List<@NonNull LocalizedString> getAlternatives() { 577 return alternatives; 578 } 579 580 /** 581 * Builder used to construct instances of {@link LocalizedString}. 582 * <p> 583 * This class is intended for use by a single thread. 584 * 585 * @author <a href="https://revetkn.com">Mark Allen</a> 586 */ 587 @NotThreadSafe 588 public static class Builder { 589 @NonNull 590 private final String key; 591 @Nullable 592 private String translation; 593 @Nullable 594 private String commentary; 595 @Nullable 596 private Map<@NonNull String, ? extends @NonNull PlaceholderDefinition> placeholderDefinitions; 597 @Nullable 598 private List<@NonNull LocalizedString> alternatives; 599 600 /** 601 * Constructs a localized string builder with the given key. 602 * 603 * @param key this string's translation key, not null 604 */ 605 public Builder(@NonNull String key) { 606 requireNonNull(key); 607 this.key = key; 608 } 609 610 /** 611 * Applies a default translation to this builder. 612 * 613 * @param translation a default translation, may be null 614 * @return this builder instance, useful for chaining. not null 615 */ 616 @NonNull 617 public Builder translation(@Nullable String translation) { 618 this.translation = translation; 619 return this; 620 } 621 622 /** 623 * Applies commentary (usage/translation notes) to this builder. 624 * 625 * @param commentary commentary (usage/translation notes), may be null 626 * @return this builder instance, useful for chaining. not null 627 */ 628 @NonNull 629 public Builder commentary(@Nullable String commentary) { 630 this.commentary = commentary; 631 return this; 632 } 633 634 /** 635 * Applies generated-placeholder definitions to this builder. 636 * 637 * @param placeholderDefinitions generated-placeholder definitions keyed by placeholder name, may be null 638 * @return this builder instance, useful for chaining. not null 639 * @since 3.0.0 640 */ 641 @NonNull 642 public Builder placeholderDefinitions( 643 @Nullable Map<@NonNull String, ? extends @NonNull PlaceholderDefinition> placeholderDefinitions) { 644 this.placeholderDefinitions = placeholderDefinitions; 645 return this; 646 } 647 648 /** 649 * Applies alternative expression-driven translations to this builder. 650 * 651 * @param alternatives alternative expression-driven translations, may be null 652 * @return this builder instance, useful for chaining. not null 653 */ 654 @NonNull 655 public Builder alternatives(@Nullable List<@NonNull LocalizedString> alternatives) { 656 this.alternatives = alternatives; 657 return this; 658 } 659 660 /** 661 * Constructs an instance of {@link LocalizedString}. 662 * 663 * @return an instance of {@link LocalizedString}, not null 664 * @throws NullPointerException if a configured collection contains a null key, value, or element 665 */ 666 @NonNull 667 public LocalizedString build() { 668 return new LocalizedString(key, translation, commentary, placeholderDefinitions, alternatives); 669 } 670 } 671 672 /** 673 * Closed base type for generated-placeholder definitions. 674 * <p> 675 * The constructor is private so callers cannot introduce definition types that Lokalized does not know how to 676 * validate or resolve. Library releases may add new concrete subtypes; consumers should therefore inspect values 677 * returned by {@link LocalizedString#getPlaceholderDefinitions()} before casting. 678 * 679 * @author <a href="https://revetkn.com">Mark Allen</a> 680 * @since 3.0.0 681 */ 682 @Immutable 683 public abstract static class PlaceholderDefinition { 684 private PlaceholderDefinition() {} 685 } 686 687 /** 688 * Container for per-language-form (gender, case, definiteness, classifier, formality, clusivity, animacy, 689 * cardinal, ordinal, phonetic) translation information. 690 * <p> 691 * Translations can be keyed either on a single value or a range of values (start and end) in the case of cardinality 692 * ranges. 693 * Runtime values may be an explicit matching {@link LanguageForm}; cardinality and ordinality additionally accept 694 * {@link PluralOperands} and the supported {@link Number} implementations documented by 695 * {@link PluralOperands#forNumber(Number)}. Phonetic maps accept {@link CharSequence} values, which are bounded by 696 * {@link TranslationRuntimeLimits#getMaximumInterpolatedOutputCharacters()} before conversion and delegation to the 697 * configured {@link PhoneticResolver}. 698 * 699 * @author <a href="https://revetkn.com">Mark Allen</a> 700 */ 701 @Immutable 702 public static final class LanguageFormTranslation extends PlaceholderDefinition { 703 @Nullable 704 private final String value; 705 @Nullable 706 private final LanguageFormTranslationRange range; 707 @NonNull 708 private final Map<@NonNull LanguageForm, @NonNull String> translationsByLanguageForm; 709 710 /** 711 * Constructs a per-language-form translation set with the given placeholder value and mapping of translations by language form. 712 * 713 * @param value the placeholder value to compare against for translation, not null 714 * @param translationsByLanguageForm the possible translations keyed by language form, not null 715 * @throws NullPointerException if the map contains a null key or value 716 */ 717 public LanguageFormTranslation(@NonNull String value, @NonNull Map<@NonNull LanguageForm, @NonNull String> translationsByLanguageForm) { 718 requireNonNull(value); 719 requireNonNull(translationsByLanguageForm); 720 721 this.value = value; 722 this.range = null; 723 this.translationsByLanguageForm = immutableTranslationsByLanguageForm(translationsByLanguageForm); 724 } 725 726 /** 727 * Constructs a per-language-form translation set with the given placeholder range and mapping of translations by language form. 728 * 729 * @param range the placeholder range to compare against for translation, not null 730 * @param translationsByLanguageForm the possible translations keyed by language form, not null 731 * @throws NullPointerException if the map contains a null key or value 732 */ 733 public LanguageFormTranslation(@NonNull LanguageFormTranslationRange range, @NonNull Map<@NonNull LanguageForm, @NonNull String> translationsByLanguageForm) { 734 requireNonNull(range); 735 requireNonNull(translationsByLanguageForm); 736 737 this.value = null; 738 this.range = range; 739 this.translationsByLanguageForm = immutableTranslationsByLanguageForm(translationsByLanguageForm); 740 } 741 742 @NonNull 743 private static Map<@NonNull LanguageForm, @NonNull String> immutableTranslationsByLanguageForm( 744 @NonNull Map<@NonNull LanguageForm, @NonNull String> translationsByLanguageForm) { 745 Map<@NonNull LanguageForm, @NonNull String> copy = new LinkedHashMap<>(); 746 747 for (Map.Entry<@NonNull LanguageForm, @NonNull String> entry : translationsByLanguageForm.entrySet()) 748 copy.put( 749 requireNonNull(entry.getKey(), "translationsByLanguageForm must not contain a null key"), 750 requireNonNull(entry.getValue(), "translationsByLanguageForm must not contain a null value")); 751 752 return Collections.unmodifiableMap(copy); 753 } 754 755 /** 756 * Generates a {@code String} representation of this object. 757 * 758 * @return a string representation of this object, not null 759 */ 760 @Override 761 @NonNull 762 public String toString() { 763 if (getRange().isPresent()) 764 return format("%s{range=%s, translationsByLanguageForm=%s}", getClass().getSimpleName(), getRange().get(), getTranslationsByLanguageForm()); 765 766 return format("%s{value=%s, translationsByLanguageForm=%s}", getClass().getSimpleName(), getValue().get(), getTranslationsByLanguageForm()); 767 } 768 769 /** 770 * Checks if this object is equal to another one. 771 * 772 * @param other the object to check, null returns false 773 * @return true if this is equal to the other object, false otherwise 774 */ 775 @Override 776 public boolean equals(@Nullable Object other) { 777 if (this == other) 778 return true; 779 780 if (other == null || !getClass().equals(other.getClass())) 781 return false; 782 783 LanguageFormTranslation languageFormTranslation = (LanguageFormTranslation) other; 784 785 return Objects.equals(getValue(), languageFormTranslation.getValue()) 786 && Objects.equals(getRange(), languageFormTranslation.getRange()) 787 && Objects.equals(getTranslationsByLanguageForm(), languageFormTranslation.getTranslationsByLanguageForm()); 788 } 789 790 /** 791 * A hash code for this object. 792 * 793 * @return a suitable hash code 794 */ 795 @Override 796 public int hashCode() { 797 return Objects.hash(getValue(), getRange(), getTranslationsByLanguageForm()); 798 } 799 800 /** 801 * Gets the value for this per-language-form translation set. 802 * 803 * @return the value for this per-language-form translation set, not null 804 */ 805 @NonNull 806 public Optional<@NonNull String> getValue() { 807 return Optional.ofNullable(value); 808 } 809 810 /** 811 * Gets the range for this per-language-form translation set. 812 * 813 * @return the range for this per-language-form translation set, not null 814 */ 815 @NonNull 816 public Optional<@NonNull LanguageFormTranslationRange> getRange() { 817 return Optional.ofNullable(range); 818 } 819 820 /** 821 * Gets the translations by language form for this per-language-form translation set. 822 * 823 * @return the translations by language form for this per-language-form translation set, not null 824 */ 825 @NonNull 826 public Map<@NonNull LanguageForm, @NonNull String> getTranslationsByLanguageForm() { 827 return translationsByLanguageForm; 828 } 829 } 830 831 /** 832 * A generated fragment with a required default translation and optional ordered expression-driven alternatives. 833 * <p> 834 * The first alternative whose expression matches supplies the fragment. If none match, the default translation is 835 * used. The one-argument constructor creates a translation-only scoped fragment. 836 * 837 * @author <a href="https://revetkn.com">Mark Allen</a> 838 * @since 3.0.0 839 */ 840 @Immutable 841 public static final class ExpressionTranslation extends PlaceholderDefinition { 842 @NonNull 843 private final String translation; 844 @NonNull 845 private final List<@NonNull ExpressionAlternative> alternatives; 846 847 /** 848 * Constructs a translation-only generated fragment. 849 * 850 * @param translation the fragment translation, not null 851 */ 852 public ExpressionTranslation(@NonNull String translation) { 853 requireNonNull(translation); 854 855 this.translation = translation; 856 this.alternatives = Collections.emptyList(); 857 } 858 859 /** 860 * Constructs a generated fragment with a default translation and ordered expression-driven alternatives. 861 * 862 * @param translation the default fragment translation, not null 863 * @param alternatives the ordered expression-driven alternatives, not null or empty 864 * @throws NullPointerException if {@code alternatives} contains a null element 865 */ 866 public ExpressionTranslation(@NonNull String translation, 867 @NonNull List<@NonNull ExpressionAlternative> alternatives) { 868 requireNonNull(translation); 869 requireNonNull(alternatives); 870 871 if (alternatives.isEmpty()) 872 throw new IllegalArgumentException("alternatives must not be empty; use ExpressionTranslation(String) for a translation-only fragment"); 873 874 this.translation = translation; 875 List<@NonNull ExpressionAlternative> alternativesCopy = new ArrayList<>(alternatives.size()); 876 877 for (ExpressionAlternative alternative : alternatives) 878 alternativesCopy.add(requireNonNull(alternative, "alternatives must not contain null elements")); 879 880 this.alternatives = Collections.unmodifiableList(alternativesCopy); 881 } 882 883 /** 884 * Generates a {@code String} representation of this object. 885 * 886 * @return a string representation of this object, not null 887 */ 888 @Override 889 @NonNull 890 public String toString() { 891 return format("%s{translation=%s, alternatives=%s}", getClass().getSimpleName(), getTranslation(), 892 getAlternatives()); 893 } 894 895 /** 896 * Checks if this object is equal to another one. 897 * 898 * @param other the object to check, null returns false 899 * @return true if this is equal to the other object, false otherwise 900 */ 901 @Override 902 public boolean equals(@Nullable Object other) { 903 if (this == other) 904 return true; 905 906 if (other == null || !getClass().equals(other.getClass())) 907 return false; 908 909 ExpressionTranslation expressionTranslation = (ExpressionTranslation) other; 910 911 return Objects.equals(getTranslation(), expressionTranslation.getTranslation()) 912 && Objects.equals(getAlternatives(), expressionTranslation.getAlternatives()); 913 } 914 915 /** 916 * A hash code for this object. 917 * 918 * @return a suitable hash code 919 */ 920 @Override 921 public int hashCode() { 922 return Objects.hash(getTranslation(), getAlternatives()); 923 } 924 925 /** 926 * Gets the default fragment translation. 927 * 928 * @return the default fragment translation, not null 929 */ 930 @NonNull 931 public String getTranslation() { 932 return translation; 933 } 934 935 /** 936 * Gets the ordered expression-driven alternatives. 937 * 938 * @return the ordered expression-driven alternatives, empty for a translation-only fragment, not null 939 */ 940 @NonNull 941 public List<@NonNull ExpressionAlternative> getAlternatives() { 942 return alternatives; 943 } 944 } 945 946 /** 947 * An expression and the generated fragment translation selected when that expression matches. 948 * 949 * @author <a href="https://revetkn.com">Mark Allen</a> 950 * @since 3.0.0 951 */ 952 @Immutable 953 public static final class ExpressionAlternative { 954 @NonNull 955 private final String expression; 956 @NonNull 957 private final String translation; 958 959 /** 960 * Constructs an expression-driven fragment alternative. 961 * 962 * @param expression the localization expression, not null 963 * @param translation the fragment translation, not null 964 */ 965 public ExpressionAlternative(@NonNull String expression, @NonNull String translation) { 966 requireNonNull(expression); 967 requireNonNull(translation); 968 969 this.expression = expression; 970 this.translation = translation; 971 } 972 973 /** 974 * Generates a {@code String} representation of this object. 975 * 976 * @return a string representation of this object, not null 977 */ 978 @Override 979 @NonNull 980 public String toString() { 981 return format("%s{expression=%s, translation=%s}", getClass().getSimpleName(), getExpression(), 982 getTranslation()); 983 } 984 985 /** 986 * Checks if this object is equal to another one. 987 * 988 * @param other the object to check, null returns false 989 * @return true if this is equal to the other object, false otherwise 990 */ 991 @Override 992 public boolean equals(@Nullable Object other) { 993 if (this == other) 994 return true; 995 996 if (other == null || !getClass().equals(other.getClass())) 997 return false; 998 999 ExpressionAlternative expressionAlternative = (ExpressionAlternative) other; 1000 1001 return Objects.equals(getExpression(), expressionAlternative.getExpression()) 1002 && Objects.equals(getTranslation(), expressionAlternative.getTranslation()); 1003 } 1004 1005 /** 1006 * A hash code for this object. 1007 * 1008 * @return a suitable hash code 1009 */ 1010 @Override 1011 public int hashCode() { 1012 return Objects.hash(getExpression(), getTranslation()); 1013 } 1014 1015 /** 1016 * Gets the localization expression. 1017 * 1018 * @return the localization expression, not null 1019 */ 1020 @NonNull 1021 public String getExpression() { 1022 return expression; 1023 } 1024 1025 /** 1026 * Gets the fragment translation selected when this expression matches. 1027 * 1028 * @return the fragment translation, not null 1029 */ 1030 @NonNull 1031 public String getTranslation() { 1032 return translation; 1033 } 1034 } 1035 1036 /** 1037 * Container for per-language-form cardinality translation information over a range (start, end) of values. 1038 * 1039 * @author <a href="https://revetkn.com">Mark Allen</a> 1040 */ 1041 @Immutable 1042 public static final class LanguageFormTranslationRange { 1043 @NonNull 1044 private final String start; 1045 @NonNull 1046 private final String end; 1047 1048 /** 1049 * Constructs a translation range with the given start and end values. 1050 * 1051 * @param start the start value of the range, not null 1052 * @param end the end value of the range, not null 1053 */ 1054 public LanguageFormTranslationRange(@NonNull String start, @NonNull String end) { 1055 requireNonNull(start); 1056 requireNonNull(end); 1057 1058 this.start = start; 1059 this.end = end; 1060 } 1061 1062 /** 1063 * Generates a {@code String} representation of this object. 1064 * 1065 * @return a string representation of this object, not null 1066 */ 1067 @Override 1068 @NonNull 1069 public String toString() { 1070 return format("%s{start=%s, end=%s}", getClass().getSimpleName(), getStart(), getEnd()); 1071 } 1072 1073 /** 1074 * Checks if this object is equal to another one. 1075 * 1076 * @param other the object to check, null returns false 1077 * @return true if this is equal to the other object, false otherwise 1078 */ 1079 @Override 1080 public boolean equals(@Nullable Object other) { 1081 if (this == other) 1082 return true; 1083 1084 if (other == null || !getClass().equals(other.getClass())) 1085 return false; 1086 1087 LanguageFormTranslationRange languageFormTranslationRange = (LanguageFormTranslationRange) other; 1088 1089 return Objects.equals(getStart(), languageFormTranslationRange.getStart()) 1090 && Objects.equals(getEnd(), languageFormTranslationRange.getEnd()); 1091 } 1092 1093 /** 1094 * A hash code for this object. 1095 * 1096 * @return a suitable hash code 1097 */ 1098 @Override 1099 public int hashCode() { 1100 return Objects.hash(getStart(), getEnd()); 1101 } 1102 1103 /** 1104 * The start value for this range. 1105 * 1106 * @return the start value for this range, not null 1107 */ 1108 @NonNull 1109 public String getStart() { 1110 return start; 1111 } 1112 1113 /** 1114 * The end value for this range. 1115 * 1116 * @return the end value for this range, not null 1117 */ 1118 @NonNull 1119 public String getEnd() { 1120 return end; 1121 } 1122 } 1123}