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}