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.ArrayList;
025import java.util.Collections;
026import java.util.LinkedHashMap;
027import java.util.LinkedHashSet;
028import java.util.List;
029import java.util.Map;
030import java.util.Objects;
031import java.util.Optional;
032import java.util.Set;
033import java.util.stream.Collectors;
034
035import static java.lang.String.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 *
043 * @author <a href="https://revetkn.com">Mark Allen</a>
044 */
045@Immutable
046public class LocalizedString {
047  @NonNull
048  private final String key;
049  @Nullable
050  private final String translation;
051  @Nullable
052  private final String commentary;
053  @Nullable
054  private final List<@NonNull Token> expressionTokens;
055  @NonNull
056  private final Map<@NonNull String, @NonNull PlaceholderMetadata> placeholderMetadataByPlaceholder;
057  @NonNull
058  private final Map<@NonNull String, @NonNull LanguageFormTranslation> languageFormTranslationsByPlaceholder;
059  @NonNull
060  private final List<@NonNull LocalizedString> alternatives;
061
062  /**
063   * Constructs a localized string with a key, default translation, and additional translation rules.
064   *
065   * @param key                                   this string's translation key, not null
066   * @param translation                           this string's default translation, may be null
067   * @param commentary                            this string's commentary (usage/translation notes), may be null
068   * @param placeholderMetadataByPlaceholder      per-placeholder translator metadata, may be null
069   * @param languageFormTranslationsByPlaceholder per-language-form translations that correspond to a placeholder value, may be null
070   * @param alternatives                          alternative expression-driven translations for this string, may be null
071   */
072  protected LocalizedString(@NonNull String key, @Nullable String translation, @Nullable String commentary,
073                            @Nullable Map<@NonNull String, @NonNull PlaceholderMetadata> placeholderMetadataByPlaceholder,
074                            @Nullable Map<@NonNull String, @NonNull LanguageFormTranslation> languageFormTranslationsByPlaceholder,
075                            @Nullable List<@NonNull LocalizedString> alternatives,
076                            @Nullable List<@NonNull Token> expressionTokens) {
077    requireNonNull(key);
078
079    this.key = key;
080    this.translation = translation;
081    this.commentary = commentary;
082    this.expressionTokens = expressionTokens == null ? null : Collections.unmodifiableList(new ArrayList<>(expressionTokens));
083
084    if (placeholderMetadataByPlaceholder == null) {
085      this.placeholderMetadataByPlaceholder = Collections.emptyMap();
086    } else {
087      this.placeholderMetadataByPlaceholder = Collections.unmodifiableMap(new LinkedHashMap<>(placeholderMetadataByPlaceholder));
088    }
089
090    if (languageFormTranslationsByPlaceholder == null) {
091      this.languageFormTranslationsByPlaceholder = Collections.emptyMap();
092    } else {
093      // Defensive copy to unmodifiable map
094      this.languageFormTranslationsByPlaceholder = Collections.unmodifiableMap(new LinkedHashMap<>(languageFormTranslationsByPlaceholder));
095    }
096
097    // Defensive copy to unmodifiable list
098    this.alternatives = alternatives == null ? Collections.emptyList() : Collections.unmodifiableList(new ArrayList<>(alternatives));
099
100    if (translation == null && alternatives.size() == 0)
101      throw new IllegalArgumentException(format("You must provide either a translation or at least one alternative expression. " +
102          "Offending key was '%s'", key));
103  }
104
105  /**
106   * Generates a {@code String} representation of this object.
107   *
108   * @return a string representation of this object, not null
109   */
110  @Override
111  @NonNull
112  public String toString() {
113    List<@NonNull String> components = new ArrayList<>(6);
114
115    components.add(format("key=%s", getKey()));
116
117    if (getTranslation().isPresent())
118      components.add(format("translation=%s", getTranslation().get()));
119
120    if (getCommentary().isPresent())
121      components.add(format("commentary=%s", getCommentary().get()));
122
123    if (getPlaceholderMetadataByPlaceholder().size() > 0)
124      components.add(format("placeholderMetadataByPlaceholder=%s", getPlaceholderMetadataByPlaceholder()));
125
126    if (getLanguageFormTranslationsByPlaceholder().size() > 0)
127      components.add(format("languageFormTranslationsByPlaceholder=%s", getLanguageFormTranslationsByPlaceholder()));
128
129    if (getAlternatives().size() > 0)
130      components.add(format("alternatives=%s", getAlternatives()));
131
132    return format("%s{%s}", getClass().getSimpleName(), components.stream().collect(Collectors.joining(", ")));
133  }
134
135  /**
136   * Checks if this object is equal to another one.
137   *
138   * @param other the object to check, null returns false
139   * @return true if this is equal to the other object, false otherwise
140   */
141  @Override
142  public boolean equals(@Nullable Object other) {
143    if (this == other)
144      return true;
145
146    if (other == null || !getClass().equals(other.getClass()))
147      return false;
148
149    LocalizedString localizedString = (LocalizedString) other;
150
151    return Objects.equals(getKey(), localizedString.getKey())
152        && Objects.equals(getTranslation(), localizedString.getTranslation())
153        && Objects.equals(getCommentary(), localizedString.getCommentary())
154        && Objects.equals(getPlaceholderMetadataByPlaceholder(), localizedString.getPlaceholderMetadataByPlaceholder())
155        && Objects.equals(getLanguageFormTranslationsByPlaceholder(), localizedString.getLanguageFormTranslationsByPlaceholder())
156        && Objects.equals(getAlternatives(), localizedString.getAlternatives());
157  }
158
159  /**
160   * A hash code for this object.
161   *
162   * @return a suitable hash code
163   */
164  @Override
165  public int hashCode() {
166    return Objects.hash(getKey(), getTranslation(), getCommentary(), getPlaceholderMetadataByPlaceholder(),
167        getLanguageFormTranslationsByPlaceholder(), getAlternatives());
168  }
169
170  /**
171   * Gets this string's translation key.
172   *
173   * @return this string's translation key, not null
174   */
175  @NonNull
176  public String getKey() {
177    return key;
178  }
179
180  /**
181   * Gets this string's default translation, if available.
182   *
183   * @return this string's default translation, not null
184   */
185  @NonNull
186  public Optional<String> getTranslation() {
187    return Optional.ofNullable(translation);
188  }
189
190  /**
191   * Gets this string's commentary (usage/translation notes).
192   *
193   * @return this string's commentary, not null
194   */
195  @NonNull
196  public Optional<String> getCommentary() {
197    return Optional.ofNullable(commentary);
198  }
199
200  /**
201   * Gets per-placeholder translator metadata for this string.
202   * <p>
203   * This metadata can be used to document placeholder meaning, type, examples, and allowed values for translators or tooling.
204   *
205   * @return per-placeholder translator metadata for this string, not null
206   */
207  @NonNull
208  public Map<@NonNull String, @NonNull PlaceholderMetadata> getPlaceholderMetadataByPlaceholder() {
209    return placeholderMetadataByPlaceholder;
210  }
211
212  /**
213   * Gets per-language-form translations that correspond to a placeholder value.
214   * <p>
215   * For example, language form {@code GENDER_MASCULINE} might be translated as {@code He} for placeholder {@code subject},
216   * or a selector-based placeholder might use both {@code GENDER} and {@code CASE} to choose a single translation rule.
217   *
218   * @return per-language-form translations that correspond to a placeholder value, not null
219   */
220  @NonNull
221  public Map<@NonNull String, @NonNull LanguageFormTranslation> getLanguageFormTranslationsByPlaceholder() {
222    return languageFormTranslationsByPlaceholder;
223  }
224
225  /**
226   * Gets alternative expression-driven translations for this string.
227   * <p>
228   * In this context, the {@code key} for each alternative is a localization expression, not a translation key.
229   * <p>
230   * 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}.
231   *
232   * @return alternative expression-driven translations for this string, not null
233   */
234  @NonNull
235  public List<@NonNull LocalizedString> getAlternatives() {
236    return alternatives;
237  }
238
239  @Nullable
240  List<@NonNull Token> getExpressionTokens() {
241    return expressionTokens;
242  }
243
244
245  /**
246   * Builder used to construct instances of {@link LocalizedString}.
247   * <p>
248   * This class is intended for use by a single thread.
249   *
250   * @author <a href="https://revetkn.com">Mark Allen</a>
251   */
252  @NotThreadSafe
253  public static class Builder {
254    @NonNull
255    private final String key;
256    @Nullable
257    private String translation;
258    @Nullable
259    private String commentary;
260    @Nullable
261    private Map<@NonNull String, @NonNull PlaceholderMetadata> placeholderMetadataByPlaceholder;
262    @Nullable
263    private Map<@NonNull String, @NonNull LanguageFormTranslation> languageFormTranslationsByPlaceholder;
264    @Nullable
265    private List<@NonNull LocalizedString> alternatives;
266    @Nullable
267    private List<@NonNull Token> expressionTokens;
268
269    /**
270     * Constructs a localized string builder with the given key.
271     *
272     * @param key this string's translation key, not null
273     */
274    public Builder(@NonNull String key) {
275      requireNonNull(key);
276      this.key = key;
277    }
278
279    /**
280     * Applies a default translation to this builder.
281     *
282     * @param translation a default translation, may be null
283     * @return this builder instance, useful for chaining. not null
284     */
285    @NonNull
286    public Builder translation(@Nullable String translation) {
287      this.translation = translation;
288      return this;
289    }
290
291    /**
292     * Applies commentary (usage/translation notes) to this builder.
293     *
294     * @param commentary commentary (usage/translation notes), may be null
295     * @return this builder instance, useful for chaining. not null
296     */
297    @NonNull
298    public Builder commentary(@Nullable String commentary) {
299      this.commentary = commentary;
300      return this;
301    }
302
303    /**
304     * Applies per-placeholder translator metadata to this builder.
305     *
306     * @param placeholderMetadataByPlaceholder per-placeholder translator metadata, may be null
307     * @return this builder instance, useful for chaining. not null
308     */
309    @NonNull
310    public Builder placeholderMetadataByPlaceholder(
311        @Nullable Map<@NonNull String, @NonNull PlaceholderMetadata> placeholderMetadataByPlaceholder) {
312      this.placeholderMetadataByPlaceholder = placeholderMetadataByPlaceholder;
313      return this;
314    }
315
316    /**
317     * Applies per-language-form translations to this builder.
318     *
319     * @param languageFormTranslationsByPlaceholder per-language-form or selector-driven translations, may be null
320     * @return this builder instance, useful for chaining. not null
321     */
322    @NonNull
323    public Builder languageFormTranslationsByPlaceholder(
324        @Nullable Map<@NonNull String, @NonNull LanguageFormTranslation> languageFormTranslationsByPlaceholder) {
325      this.languageFormTranslationsByPlaceholder = languageFormTranslationsByPlaceholder;
326      return this;
327    }
328
329    /**
330     * Applies alternative expression-driven translations to this builder.
331     *
332     * @param alternatives alternative expression-driven translations, may be null
333     * @return this builder instance, useful for chaining. not null
334     */
335    @NonNull
336    public Builder alternatives(@Nullable List<@NonNull LocalizedString> alternatives) {
337      this.alternatives = alternatives;
338      return this;
339    }
340
341    @NonNull
342    Builder expressionTokens(@Nullable List<@NonNull Token> expressionTokens) {
343      this.expressionTokens = expressionTokens;
344      return this;
345    }
346
347    /**
348     * Constructs an instance of {@link LocalizedString}.
349     *
350     * @return an instance of {@link LocalizedString}, not null
351     */
352    @NonNull
353    public LocalizedString build() {
354      return new LocalizedString(key, translation, commentary, placeholderMetadataByPlaceholder,
355          languageFormTranslationsByPlaceholder, alternatives, expressionTokens);
356    }
357  }
358
359  /**
360   * Translator-facing metadata for a single placeholder.
361   * <p>
362   * This metadata is informational and is not used by runtime string evaluation. It is intended to improve translator
363   * context and to support documentation or linting tools.
364   *
365   * @author <a href="https://revetkn.com">Mark Allen</a>
366   */
367  @Immutable
368  public static class PlaceholderMetadata {
369    @Nullable
370    private final String type;
371    @Nullable
372    private final String commentary;
373    @Nullable
374    private final String example;
375    @NonNull
376    private final Set<@NonNull String> allowedValues;
377
378    /**
379     * Constructs placeholder metadata.
380     *
381     * @param type          translator-facing type label, for example {@code STRING}, {@code NUMBER}, or {@code GENDER}, may be null
382     * @param commentary    commentary describing how the placeholder is used, may be null
383     * @param example       example placeholder value, may be null
384     * @param allowedValues allowed values for the placeholder as an insertion-ordered set, may be null
385     */
386    public PlaceholderMetadata(@Nullable String type, @Nullable String commentary, @Nullable String example,
387                               @Nullable Set<@NonNull String> allowedValues) {
388      if (type == null && commentary == null && example == null && (allowedValues == null || allowedValues.isEmpty()))
389        throw new IllegalArgumentException(format("%s requires at least one metadata field", getClass().getSimpleName()));
390
391      this.type = type;
392      this.commentary = commentary;
393      this.example = example;
394      this.allowedValues = allowedValues == null ? Collections.emptySet() : Collections.unmodifiableSet(new LinkedHashSet<>(allowedValues));
395    }
396
397    /**
398     * Generates a {@code String} representation of this object.
399     *
400     * @return a string representation of this object, not null
401     */
402    @Override
403    @NonNull
404    public String toString() {
405      List<@NonNull String> components = new ArrayList<>(4);
406
407      if (getType().isPresent())
408        components.add(format("type=%s", getType().get()));
409
410      if (getCommentary().isPresent())
411        components.add(format("commentary=%s", getCommentary().get()));
412
413      if (getExample().isPresent())
414        components.add(format("example=%s", getExample().get()));
415
416      if (getAllowedValues().size() > 0)
417        components.add(format("allowedValues=%s", getAllowedValues()));
418
419      return format("%s{%s}", getClass().getSimpleName(), components.stream().collect(Collectors.joining(", ")));
420    }
421
422    /**
423     * Checks if this object is equal to another one.
424     *
425     * @param other the object to check, null returns false
426     * @return true if this is equal to the other object, false otherwise
427     */
428    @Override
429    public boolean equals(@Nullable Object other) {
430      if (this == other)
431        return true;
432
433      if (other == null || !getClass().equals(other.getClass()))
434        return false;
435
436      PlaceholderMetadata placeholderMetadata = (PlaceholderMetadata) other;
437
438      return Objects.equals(getType(), placeholderMetadata.getType())
439          && Objects.equals(getCommentary(), placeholderMetadata.getCommentary())
440          && Objects.equals(getExample(), placeholderMetadata.getExample())
441          && Objects.equals(getAllowedValues(), placeholderMetadata.getAllowedValues());
442    }
443
444    /**
445     * A hash code for this object.
446     *
447     * @return a suitable hash code
448     */
449    @Override
450    public int hashCode() {
451      return Objects.hash(getType(), getCommentary(), getExample(), getAllowedValues());
452    }
453
454    /**
455     * Gets the translator-facing type label for this placeholder.
456     *
457     * @return the translator-facing type label for this placeholder, not null
458     */
459    @NonNull
460    public Optional<String> getType() {
461      return Optional.ofNullable(type);
462    }
463
464    /**
465     * Gets the commentary for this placeholder.
466     *
467     * @return the commentary for this placeholder, not null
468     */
469    @NonNull
470    public Optional<String> getCommentary() {
471      return Optional.ofNullable(commentary);
472    }
473
474    /**
475     * Gets an example value for this placeholder.
476     *
477     * @return an example value for this placeholder, not null
478     */
479    @NonNull
480    public Optional<String> getExample() {
481      return Optional.ofNullable(example);
482    }
483
484    /**
485     * Gets the allowed values for this placeholder.
486     *
487     * @return the allowed values for this placeholder, not null
488     */
489    @NonNull
490    public Set<@NonNull String> getAllowedValues() {
491      return allowedValues;
492    }
493  }
494
495  /**
496   * Container for per-language-form (gender, case, definiteness, classifier, formality, clusivity, animacy,
497   * cardinal, ordinal, phonetic) translation information.
498   * <p>
499   * Translations can be keyed either on a single value, a range of values (start and end) in the case of cardinality ranges,
500   * or a selector-based rule set for multi-axis agreement.
501   * <p>
502   * Exactly one translation mode is active for each instance.
503   *
504   * @author <a href="https://revetkn.com">Mark Allen</a>
505   */
506  @Immutable
507  public static class LanguageFormTranslation {
508    @Nullable
509    private final String value;
510    @Nullable
511    private final LanguageFormTranslationRange range;
512    @NonNull
513    private final Map<@NonNull LanguageForm, @NonNull String> translationsByLanguageForm;
514    @NonNull
515    private final List<@NonNull LanguageFormSelector> selectors;
516    @NonNull
517    private final List<@NonNull LanguageFormTranslationRule> translationRules;
518
519    /**
520     * Constructs a per-language-form translation set with the given placeholder value and mapping of translations by language form.
521     *
522     * @param value                      the placeholder value to compare against for translation, not null
523     * @param translationsByLanguageForm the possible translations keyed by language form, not null
524     */
525    public LanguageFormTranslation(@NonNull String value, @NonNull Map<@NonNull LanguageForm, @NonNull String> translationsByLanguageForm) {
526      requireNonNull(value);
527      requireNonNull(translationsByLanguageForm);
528
529      this.value = value;
530      this.range = null;
531      this.translationsByLanguageForm = Collections.unmodifiableMap(new LinkedHashMap<>(translationsByLanguageForm));
532      this.selectors = Collections.emptyList();
533      this.translationRules = Collections.emptyList();
534    }
535
536    /**
537     * Constructs a per-language-form translation set with the given placeholder range and mapping of translations by language form.
538     *
539     * @param range                      the placeholder range to compare against for translation, not null
540     * @param translationsByLanguageForm the possible translations keyed by language form, not null
541     */
542    public LanguageFormTranslation(@NonNull LanguageFormTranslationRange range, @NonNull Map<@NonNull LanguageForm, @NonNull String> translationsByLanguageForm) {
543      requireNonNull(range);
544      requireNonNull(translationsByLanguageForm);
545
546      this.value = null;
547      this.range = range;
548      this.translationsByLanguageForm = Collections.unmodifiableMap(new LinkedHashMap<>(translationsByLanguageForm));
549      this.selectors = Collections.emptyList();
550      this.translationRules = Collections.emptyList();
551    }
552
553    /**
554     * Constructs a selector-based translation set with multiple agreement dimensions and ordered translation rules.
555     *
556     * @param selectors        the selector definitions that determine which language forms to evaluate, not null
557     * @param translationRules the translation rules to evaluate against the selector values, not null
558     */
559    public LanguageFormTranslation(@NonNull List<@NonNull LanguageFormSelector> selectors,
560                                   @NonNull List<@NonNull LanguageFormTranslationRule> translationRules) {
561      requireNonNull(selectors);
562      requireNonNull(translationRules);
563
564      if (selectors.isEmpty())
565        throw new IllegalArgumentException(format("Selector-based %s instances require at least one selector",
566            getClass().getSimpleName()));
567
568      if (translationRules.isEmpty())
569        throw new IllegalArgumentException(format("Selector-based %s instances require at least one translation rule",
570            getClass().getSimpleName()));
571
572      this.value = null;
573      this.range = null;
574      this.translationsByLanguageForm = Collections.emptyMap();
575      this.selectors = Collections.unmodifiableList(new ArrayList<>(selectors));
576      this.translationRules = Collections.unmodifiableList(new ArrayList<>(translationRules));
577    }
578
579    /**
580     * Generates a {@code String} representation of this object.
581     *
582     * @return a string representation of this object, not null
583     */
584    @Override
585    @NonNull
586    public String toString() {
587      if (isSelectorDriven())
588        return format("%s{selectors=%s, translationRules=%s}", getClass().getSimpleName(), getSelectors(), getTranslationRules());
589
590      if (getRange().isPresent())
591        return format("%s{range=%s, translationsByLanguageForm=%s}", getClass().getSimpleName(), getRange().get(), getTranslationsByLanguageForm());
592
593      return format("%s{value=%s, translationsByLanguageForm=%s}", getClass().getSimpleName(), getValue().get(), getTranslationsByLanguageForm());
594    }
595
596    /**
597     * Checks if this object is equal to another one.
598     *
599     * @param other the object to check, null returns false
600     * @return true if this is equal to the other object, false otherwise
601     */
602    @Override
603    public boolean equals(@Nullable Object other) {
604      if (this == other)
605        return true;
606
607      if (other == null || !getClass().equals(other.getClass()))
608        return false;
609
610      LanguageFormTranslation languageFormTranslation = (LanguageFormTranslation) other;
611
612      return Objects.equals(getValue(), languageFormTranslation.getValue())
613          && Objects.equals(getRange(), languageFormTranslation.getRange())
614          && Objects.equals(getTranslationsByLanguageForm(), languageFormTranslation.getTranslationsByLanguageForm())
615          && Objects.equals(getSelectors(), languageFormTranslation.getSelectors())
616          && Objects.equals(getTranslationRules(), languageFormTranslation.getTranslationRules());
617    }
618
619    /**
620     * A hash code for this object.
621     *
622     * @return a suitable hash code
623     */
624    @Override
625    public int hashCode() {
626      return Objects.hash(getValue(), getRange(), getTranslationsByLanguageForm(), getSelectors(), getTranslationRules());
627    }
628
629    /**
630     * Gets the value for this per-language-form translation set.
631     *
632     * @return the value for this per-language-form translation set, not null
633     */
634    @NonNull
635    public Optional<String> getValue() {
636      return Optional.ofNullable(value);
637    }
638
639    /**
640     * Gets the range for this per-language-form translation set.
641     *
642     * @return the range for this per-language-form translation set, not null
643     */
644    @NonNull
645    public Optional<LanguageFormTranslationRange> getRange() {
646      return Optional.ofNullable(range);
647    }
648
649    /**
650     * Indicates whether this translation uses selector-based multi-axis agreement.
651     *
652     * @return true if this translation is selector-driven, false otherwise
653     */
654    public boolean isSelectorDriven() {
655      return getSelectors().size() > 0;
656    }
657
658    /**
659     * Gets the translations by language form for this per-language-form translation set.
660     *
661     * @return the translations by language form for this per-language-form translation set, not null
662     */
663    @NonNull
664    public Map<@NonNull LanguageForm, @NonNull String> getTranslationsByLanguageForm() {
665      return translationsByLanguageForm;
666    }
667
668    /**
669     * Gets the selector definitions for this translation set.
670     *
671     * @return the selector definitions for this translation set, not null
672     */
673    @NonNull
674    public List<@NonNull LanguageFormSelector> getSelectors() {
675      return selectors;
676    }
677
678    /**
679     * Gets the ordered translation rules for this translation set.
680     *
681     * @return the ordered translation rules for this translation set, not null
682     */
683    @NonNull
684    public List<@NonNull LanguageFormTranslationRule> getTranslationRules() {
685      return translationRules;
686    }
687  }
688
689  /**
690   * Defines a selector used by a multi-axis placeholder translation.
691   * <p>
692   * Each selector identifies an application-supplied placeholder value and the language-form family to derive from it.
693   *
694   * @author <a href="https://revetkn.com">Mark Allen</a>
695   */
696  @Immutable
697  public static class LanguageFormSelector {
698    @NonNull
699    private final String value;
700    @NonNull
701    private final LanguageFormType form;
702
703    /**
704     * Constructs a selector with the given placeholder value name and language-form type.
705     *
706     * @param value the placeholder value to inspect, not null
707     * @param form  the language-form family to derive from the value, not null
708     */
709    public LanguageFormSelector(@NonNull String value, @NonNull LanguageFormType form) {
710      requireNonNull(value);
711      requireNonNull(form);
712
713      this.value = value;
714      this.form = form;
715    }
716
717    /**
718     * Generates a {@code String} representation of this object.
719     *
720     * @return a string representation of this object, not null
721     */
722    @Override
723    @NonNull
724    public String toString() {
725      return format("%s{value=%s, form=%s}", getClass().getSimpleName(), getValue(), getForm());
726    }
727
728    /**
729     * Checks if this object is equal to another one.
730     *
731     * @param other the object to check, null returns false
732     * @return true if this is equal to the other object, false otherwise
733     */
734    @Override
735    public boolean equals(@Nullable Object other) {
736      if (this == other)
737        return true;
738
739      if (other == null || !getClass().equals(other.getClass()))
740        return false;
741
742      LanguageFormSelector languageFormSelector = (LanguageFormSelector) other;
743
744      return Objects.equals(getValue(), languageFormSelector.getValue())
745          && Objects.equals(getForm(), languageFormSelector.getForm());
746    }
747
748    /**
749     * A hash code for this object.
750     *
751     * @return a suitable hash code
752     */
753    @Override
754    public int hashCode() {
755      return Objects.hash(getValue(), getForm());
756    }
757
758    /**
759     * Gets the placeholder value name for this selector.
760     *
761     * @return the placeholder value name for this selector, not null
762     */
763    @NonNull
764    public String getValue() {
765      return value;
766    }
767
768    /**
769     * Gets the language-form family for this selector.
770     *
771     * @return the language-form family for this selector, not null
772     */
773    @NonNull
774    public LanguageFormType getForm() {
775      return form;
776    }
777  }
778
779  /**
780   * Defines a selector-based translation rule for a multi-axis placeholder translation.
781   * <p>
782   * Rules may optionally provide a {@code when} map. An empty map represents the default rule.
783   *
784   * @author <a href="https://revetkn.com">Mark Allen</a>
785   */
786  @Immutable
787  public static class LanguageFormTranslationRule {
788    @NonNull
789    private final Map<@NonNull LanguageFormType, @NonNull LanguageForm> whenByLanguageFormType;
790    @NonNull
791    private final String value;
792
793    /**
794     * Constructs a default translation rule with no conditions.
795     *
796     * @param value the value to use when this rule matches, not null
797     */
798    public LanguageFormTranslationRule(@NonNull String value) {
799      this(Collections.emptyMap(), value);
800    }
801
802    /**
803     * Constructs a translation rule with the given selector conditions and value.
804     *
805     * @param whenByLanguageFormType selector conditions that must be satisfied for this rule to match, not null
806     * @param value                  the value to use when this rule matches, not null
807     */
808    public LanguageFormTranslationRule(@NonNull Map<@NonNull LanguageFormType, @NonNull LanguageForm> whenByLanguageFormType,
809                                       @NonNull String value) {
810      requireNonNull(whenByLanguageFormType);
811      requireNonNull(value);
812
813      this.whenByLanguageFormType = Collections.unmodifiableMap(new LinkedHashMap<>(whenByLanguageFormType));
814      this.value = value;
815    }
816
817    /**
818     * Generates a {@code String} representation of this object.
819     *
820     * @return a string representation of this object, not null
821     */
822    @Override
823    @NonNull
824    public String toString() {
825      return format("%s{whenByLanguageFormType=%s, value=%s}", getClass().getSimpleName(), getWhenByLanguageFormType(), getValue());
826    }
827
828    /**
829     * Checks if this object is equal to another one.
830     *
831     * @param other the object to check, null returns false
832     * @return true if this is equal to the other object, false otherwise
833     */
834    @Override
835    public boolean equals(@Nullable Object other) {
836      if (this == other)
837        return true;
838
839      if (other == null || !getClass().equals(other.getClass()))
840        return false;
841
842      LanguageFormTranslationRule languageFormTranslationRule = (LanguageFormTranslationRule) other;
843
844      return Objects.equals(getWhenByLanguageFormType(), languageFormTranslationRule.getWhenByLanguageFormType())
845          && Objects.equals(getValue(), languageFormTranslationRule.getValue());
846    }
847
848    /**
849     * A hash code for this object.
850     *
851     * @return a suitable hash code
852     */
853    @Override
854    public int hashCode() {
855      return Objects.hash(getWhenByLanguageFormType(), getValue());
856    }
857
858    /**
859     * Gets the selector conditions for this rule.
860     *
861     * @return the selector conditions for this rule, not null
862     */
863    @NonNull
864    public Map<@NonNull LanguageFormType, @NonNull LanguageForm> getWhenByLanguageFormType() {
865      return whenByLanguageFormType;
866    }
867
868    /**
869     * Gets the value for this rule.
870     *
871     * @return the value for this rule, not null
872     */
873    @NonNull
874    public String getValue() {
875      return value;
876    }
877  }
878
879  /**
880   * Container for per-language-form cardinality translation information over a range (start, end) of values.
881   *
882   * @author <a href="https://revetkn.com">Mark Allen</a>
883   */
884  @Immutable
885  public static class LanguageFormTranslationRange {
886    @NonNull
887    private final String start;
888    @NonNull
889    private final String end;
890
891    /**
892     * Constructs a translation range with the given start and end values.
893     *
894     * @param start the start value of the range, not null
895     * @param end   the end value of the range, not null
896     */
897    public LanguageFormTranslationRange(@NonNull String start, @NonNull String end) {
898      requireNonNull(start);
899      requireNonNull(end);
900
901      this.start = start;
902      this.end = end;
903    }
904
905    /**
906     * Generates a {@code String} representation of this object.
907     *
908     * @return a string representation of this object, not null
909     */
910    @Override
911    @NonNull
912    public String toString() {
913      return format("%s{start=%s, end=%s}", getClass().getSimpleName(), getStart(), getEnd());
914    }
915
916    /**
917     * Checks if this object is equal to another one.
918     *
919     * @param other the object to check, null returns false
920     * @return true if this is equal to the other object, false otherwise
921     */
922    @Override
923    public boolean equals(@Nullable Object other) {
924      if (this == other)
925        return true;
926
927      if (other == null || !getClass().equals(other.getClass()))
928        return false;
929
930      LanguageFormTranslationRange languageFormTranslationRange = (LanguageFormTranslationRange) other;
931
932      return Objects.equals(getStart(), languageFormTranslationRange.getStart())
933          && Objects.equals(getEnd(), languageFormTranslationRange.getEnd());
934    }
935
936    /**
937     * A hash code for this object.
938     *
939     * @return a suitable hash code
940     */
941    @Override
942    public int hashCode() {
943      return Objects.hash(getStart(), getEnd());
944    }
945
946    /**
947     * The start value for this range.
948     *
949     * @return the start value for this range, not null
950     */
951    @NonNull
952    public String getStart() {
953      return start;
954    }
955
956    /**
957     * The end value for this range.
958     *
959     * @return the end value for this range, not null
960     */
961    @NonNull
962    public String getEnd() {
963      return end;
964    }
965  }
966}