<|start_filename|>src/main/java/com/lokalized/ExpressionTokenizer.java<|end_filename|>
/*
 * Copyright 2017 Product Mog LLC.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.lokalized;


import javax.annotation.Nonnull;
import javax.annotation.concurrent.ThreadSafe;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import static java.lang.String.format;
import static java.util.Arrays.stream;
import static java.util.Objects.requireNonNull;

/**
 * Breaks an expression into its {@link Token} components.
 *
 * @author <a href="https://revetkn.com"><NAME></a>
 */
@ThreadSafe
class ExpressionTokenizer {
  @Nonnull
  private static final Map<TokenType, String> PATTERNS_BY_TOKEN_TYPE;
  @Nonnull
  private static final Map<TokenType, String> GROUP_NAMES_BY_TOKEN_TYPE;
  @Nonnull
  private static final Pattern TOKEN_PATTERN;
  @Nonnull
  private static final String WHITESPACE_GROUP_NAME = "WHITESPACE";
  @Nonnull
  private static final String WHITESPACE_GROUP_PATTERN = "\\p{Space}";
  @Nonnull
  private static final String ERROR_GROUP_NAME = "ERROR";
  @Nonnull
  private static final String ERROR_GROUP_PATTERN = ".+";

  static {
    // Performs double-duty: keyset maintains insertion order so the regexes are always attempted in correct order.
    // Must escape \.[]{}()*+-?^$|
    PATTERNS_BY_TOKEN_TYPE = Collections.unmodifiableMap(new LinkedHashMap<TokenType, String>() {{
      put(TokenType.GROUP_START, "\\(");
      put(TokenType.GROUP_END, "\\)");
      put(TokenType.AND, "&&");
      put(TokenType.OR, "\\|\\|");
      put(TokenType.LESS_THAN_OR_EQUAL_TO, "<=");
      put(TokenType.GREATER_THAN_OR_EQUAL_TO, ">=");
      put(TokenType.LESS_THAN, "<");
      put(TokenType.GREATER_THAN, ">");
      put(TokenType.EQUAL_TO, "==");
      put(TokenType.NOT_EQUAL_TO, "!=");
      put(TokenType.CARDINALITY_ZERO, "CARDINALITY_ZERO");
      put(TokenType.CARDINALITY_ONE, "CARDINALITY_ONE");
      put(TokenType.CARDINALITY_TWO, "CARDINALITY_TWO");
      put(TokenType.CARDINALITY_FEW, "CARDINALITY_FEW");
      put(TokenType.CARDINALITY_MANY, "CARDINALITY_MANY");
      put(TokenType.CARDINALITY_OTHER, "CARDINALITY_OTHER");
      put(TokenType.ORDINALITY_ZERO, "ORDINALITY_ZERO");
      put(TokenType.ORDINALITY_ONE, "ORDINALITY_ONE");
      put(TokenType.ORDINALITY_TWO, "ORDINALITY_TWO");
      put(TokenType.ORDINALITY_FEW, "ORDINALITY_FEW");
      put(TokenType.ORDINALITY_MANY, "ORDINALITY_MANY");
      put(TokenType.ORDINALITY_OTHER, "ORDINALITY_OTHER");
      put(TokenType.MASCULINE, "MASCULINE");
      put(TokenType.FEMININE, "FEMININE");
      put(TokenType.NEUTER, "NEUTER");
      put(TokenType.NUMBER, "-?(0|([1-9]\\d*))(\\.\\d+)?");
      put(TokenType.VARIABLE, "\\p{Alnum}+");
    }});

    // Underscore is illegal in regex group names.
    GROUP_NAMES_BY_TOKEN_TYPE = Collections.unmodifiableMap(stream(TokenType.values())
        .collect(Collectors.toMap(tokenType -> tokenType, (TokenType tokenType) -> tokenType.name().replace("_", ""))));

    StringBuilder tokenPatterns = new StringBuilder();

    tokenPatterns.append(format("(?<%s>%s)", WHITESPACE_GROUP_NAME, WHITESPACE_GROUP_PATTERN));

    for (TokenType tokenType : PATTERNS_BY_TOKEN_TYPE.keySet())
      tokenPatterns.append(format("|(?<%s>%s)", GROUP_NAMES_BY_TOKEN_TYPE.get(tokenType),
          PATTERNS_BY_TOKEN_TYPE.get(tokenType)));

    tokenPatterns.append(format("|(?<%s>%s)", ERROR_GROUP_NAME, ERROR_GROUP_PATTERN));

    // Compile and cache pattern for performance
    TOKEN_PATTERN = Pattern.compile(tokenPatterns.toString());
  }

  /**
   * Given an {@code expression}, scan it into a set of {@link Token} components.
   *
   * @param expression the expression to tokenize
   * @return the tokens that comprise the expression
   * @throws ExpressionEvaluationException if an error occurs while extracting tokens
   */
  public List<Token> extractTokens(@Nonnull String expression) {
    requireNonNull(expression);

    List<Token> tokens = new ArrayList<>();
    Matcher matcher = TOKEN_PATTERN.matcher(expression);

    while (matcher.find()) {
      for (TokenType tokenType : TokenType.values()) {
        String group = matcher.group(GROUP_NAMES_BY_TOKEN_TYPE.get(tokenType));

        if (group != null)
          tokens.add(new Token(tokenType, group));
      }

      if (matcher.group(WHITESPACE_GROUP_NAME) != null)
        continue;

      if (matcher.group(ERROR_GROUP_NAME) != null) {
        String errorGroup = matcher.group(ERROR_GROUP_NAME);

        String errorMessage =
            format("Unexpected content '%s' encountered while evaluating expression '%s'.", errorGroup, expression);

        // Special message for common error of using "=" instead of "==" for equality checks
        if (errorGroup.startsWith("= "))
          errorMessage = format("%s Did you mean '=%s'?", errorMessage, errorGroup);

        throw new ExpressionEvaluationException(errorMessage);
      }
    }

    return tokens;
  }
}

<|start_filename|>src/main/java/com/lokalized/LocalizedString.java<|end_filename|>
/*
 * Copyright 2017 Product Mog LLC.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.lokalized;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.annotation.concurrent.Immutable;
import javax.annotation.concurrent.NotThreadSafe;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;

import static java.lang.String.format;
import static java.util.Objects.requireNonNull;

/**
 * Represents a single localized string - its key, translated value, and any associated translation rules.
 * <p>
 * Normally instances are sourced from a file which contains all localized strings for a given locale.
 *
 * @author <a href="https://revetkn.com"><NAME></a>
 */
@Immutable
public class LocalizedString {
  @Nonnull
  private final String key;
  @Nullable
  private final String translation;
  @Nullable
  private final String commentary;
  @Nonnull
  private final Map<String, LanguageFormTranslation> languageFormTranslationsByPlaceholder;
  @Nonnull
  private final List<LocalizedString> alternatives;

  /**
   * Constructs a localized string with a key, default translation, and additional translation rules.
   *
   * @param key                                   this string's translation key, not null
   * @param translation                           this string's default translation, may be null
   * @param commentary                            this string's commentary (usage/translation notes), may be null
   * @param languageFormTranslationsByPlaceholder per-language-form translations that correspond to a placeholder value, may be null
   * @param alternatives                          alternative expression-driven translations for this string, may be null
   */
  protected LocalizedString(@Nonnull String key, @Nullable String translation, @Nullable String commentary,
                            @Nullable Map<String, LanguageFormTranslation> languageFormTranslationsByPlaceholder,
                            @Nullable List<LocalizedString> alternatives) {
    requireNonNull(key);

    this.key = key;
    this.translation = translation;
    this.commentary = commentary;

    if (languageFormTranslationsByPlaceholder == null) {
      this.languageFormTranslationsByPlaceholder = Collections.emptyMap();
    } else {
      // Defensive copy to unmodifiable map
      this.languageFormTranslationsByPlaceholder = Collections.unmodifiableMap(languageFormTranslationsByPlaceholder);
    }

    // Defensive copy to unmodifiable list
    this.alternatives = alternatives == null ? Collections.emptyList() : Collections.unmodifiableList(new ArrayList<>(alternatives));

    if (translation == null && alternatives.size() == 0)
      throw new IllegalArgumentException(format("You must provide either a translation or at least one alternative expression. " +
          "Offending key was '%s'", key));
  }

  /**
   * Generates a {@code String} representation of this object.
   *
   * @return a string representation of this object, not null
   */
  @Override
  @Nonnull
  public String toString() {
    List<String> components = new ArrayList<>(5);

    components.add(format("key=%s", getKey()));

    if (getTranslation().isPresent())
      components.add(format("translation=%s", getTranslation().get()));

    if (getCommentary().isPresent())
      components.add(format("commentary=%s", getCommentary().get()));

    if (getLanguageFormTranslationsByPlaceholder().size() > 0)
      components.add(format("languageFormTranslationsByPlaceholder=%s", getLanguageFormTranslationsByPlaceholder()));

    if (getAlternatives().size() > 0)
      components.add(format("alternatives=%s", getAlternatives()));

    return format("%s{%s}", getClass().getSimpleName(), components.stream().collect(Collectors.joining(", ")));
  }

  /**
   * Checks if this object is equal to another one.
   *
   * @param other the object to check, null returns false
   * @return true if this is equal to the other object, false otherwise
   */
  @Override
  public boolean equals(@Nullable Object other) {
    if (this == other)
      return true;

    if (other == null || !getClass().equals(other.getClass()))
      return false;

    LocalizedString localizedString = (LocalizedString) other;

    return Objects.equals(getKey(), localizedString.getKey())
        && Objects.equals(getTranslation(), localizedString.getTranslation())
        && Objects.equals(getCommentary(), localizedString.getCommentary())
        && Objects.equals(getLanguageFormTranslationsByPlaceholder(), localizedString.getLanguageFormTranslationsByPlaceholder())
        && Objects.equals(getAlternatives(), localizedString.getAlternatives());
  }

  /**
   * A hash code for this object.
   *
   * @return a suitable hash code
   */
  @Override
  public int hashCode() {
    return Objects.hash(getKey(), getTranslation(), getCommentary(), getLanguageFormTranslationsByPlaceholder(), getAlternatives());
  }

  /**
   * Gets this string's translation key.
   *
   * @return this string's translation key, not null
   */
  @Nonnull
  public String getKey() {
    return key;
  }

  /**
   * Gets this string's default translation, if available.
   *
   * @return this string's default translation, not null
   */
  @Nonnull
  public Optional<String> getTranslation() {
    return Optional.ofNullable(translation);
  }

  /**
   * Gets this string's commentary (usage/translation notes).
   *
   * @return this string's commentary, not null
   */
  @Nonnull
  public Optional<String> getCommentary() {
    return Optional.ofNullable(commentary);
  }

  /**
   * Gets per-language-form translations that correspond to a placeholder value.
   * <p>
   * For example, language form {@code MASCULINE} might be translated as {@code He} for placeholder {@code subject}.
   *
   * @return per-language-form translations that correspond to a placeholder value, not null
   */
  @Nonnull
  public Map<String, LanguageFormTranslation> getLanguageFormTranslationsByPlaceholder() {
    return languageFormTranslationsByPlaceholder;
  }

  /**
   * Gets alternative expression-driven translations for this string.
   * <p>
   * In this context, the {@code key} for each alternative is a localization expression, not a translation key.
   * <p>
   * 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}.
   *
   * @return alternative expression-driven translations for this string, not null
   */
  @Nonnull
  public List<LocalizedString> getAlternatives() {
    return alternatives;
  }


  /**
   * Builder used to construct instances of {@link LocalizedString}.
   * <p>
   * This class is intended for use by a single thread.
   *
   * @author <a href="https://revetkn.com"><NAME></a>
   */
  @NotThreadSafe
  public static class Builder {
    @Nonnull
    private final String key;
    @Nullable
    private String translation;
    @Nullable
    private String commentary;
    @Nullable
    private Map<String, LanguageFormTranslation> languageFormTranslationsByPlaceholder;
    @Nullable
    private List<LocalizedString> alternatives;

    /**
     * Constructs a localized string builder with the given key.
     *
     * @param key this string's translation key, not null
     */
    public Builder(@Nonnull String key) {
      requireNonNull(key);
      this.key = key;
    }

    /**
     * Applies a default translation to this builder.
     *
     * @param translation a default translation, may be null
     * @return this builder instance, useful for chaining. not null
     */
    @Nonnull
    public Builder translation(@Nullable String translation) {
      this.translation = translation;
      return this;
    }

    /**
     * Applies commentary (usage/translation notes) to this builder.
     *
     * @param commentary commentary (usage/translation notes), may be null
     * @return this builder instance, useful for chaining. not null
     */
    @Nonnull
    public Builder commentary(@Nullable String commentary) {
      this.commentary = commentary;
      return this;
    }

    /**
     * Applies per-language-form translations to this builder.
     *
     * @param languageFormTranslationsByPlaceholder per-language-form translations, may be null
     * @return this builder instance, useful for chaining. not null
     */
    @Nonnull
    public Builder languageFormTranslationsByPlaceholder(
        @Nullable Map<String, LanguageFormTranslation> languageFormTranslationsByPlaceholder) {
      this.languageFormTranslationsByPlaceholder = languageFormTranslationsByPlaceholder;
      return this;
    }

    /**
     * Applies alternative expression-driven translations to this builder.
     *
     * @param alternatives alternative expression-driven translations, may be null
     * @return this builder instance, useful for chaining. not null
     */
    @Nonnull
    public Builder alternatives(@Nullable List<LocalizedString> alternatives) {
      this.alternatives = alternatives;
      return this;
    }

    /**
     * Constructs an instance of {@link LocalizedString}.
     *
     * @return an instance of {@link LocalizedString}, not null
     */
    @Nonnull
    public LocalizedString build() {
      return new LocalizedString(key, translation, commentary, languageFormTranslationsByPlaceholder, alternatives);
    }
  }

  /**
   * Container for per-language-form (gender, cardinal, ordinal) translation information.
   * <p>
   * Translations can be keyed either on a single value or a range of values (start and end) in the case of cardinality ranges.
   * <p>
   * It is required to have either a {@code value} or {@code range}, but not both.
   *
   * @author <a href="https://revetkn.com"><NAME></a>
   */
  @Immutable
  public static class LanguageFormTranslation {
    @Nullable
    private final String value;
    @Nullable
    private final LanguageFormTranslationRange range;
    @Nonnull
    private final Map<LanguageForm, String> translationsByLanguageForm;

    /**
     * Constructs a per-language-form translation set with the given placeholder value and mapping of translations by language form.
     *
     * @param value                      the placeholder value to compare against for translation, not null
     * @param translationsByLanguageForm the possible translations keyed by language form, not null
     */
    public LanguageFormTranslation(@Nonnull String value, @Nonnull Map<LanguageForm, String> translationsByLanguageForm) {
      requireNonNull(value);
      requireNonNull(translationsByLanguageForm);

      this.value = value;
      this.range = null;
      this.translationsByLanguageForm = Collections.unmodifiableMap(new LinkedHashMap<>(translationsByLanguageForm));
    }

    /**
     * Constructs a per-language-form translation set with the given placeholder range and mapping of translations by language form.
     *
     * @param range                      the placeholder range to compare against for translation, not null
     * @param translationsByLanguageForm the possible translations keyed by language form, not null
     */
    public LanguageFormTranslation(@Nonnull LanguageFormTranslationRange range, @Nonnull Map<LanguageForm, String> translationsByLanguageForm) {
      requireNonNull(range);
      requireNonNull(translationsByLanguageForm);

      this.value = null;
      this.range = range;
      this.translationsByLanguageForm = Collections.unmodifiableMap(new LinkedHashMap<>(translationsByLanguageForm));
    }

    /**
     * Generates a {@code String} representation of this object.
     *
     * @return a string representation of this object, not null
     */
    @Override
    @Nonnull
    public String toString() {
      if (getRange().isPresent())
        return format("%s{range=%s, translationsByLanguageForm=%s", getClass().getSimpleName(), getRange().get(), getTranslationsByLanguageForm());

      return format("%s{value=%s, translationsByLanguageForm=%s", getClass().getSimpleName(), getValue().get(), getTranslationsByLanguageForm());
    }

    /**
     * Checks if this object is equal to another one.
     *
     * @param other the object to check, null returns false
     * @return true if this is equal to the other object, false otherwise
     */
    @Override
    public boolean equals(@Nullable Object other) {
      if (this == other)
        return true;

      if (other == null || !getClass().equals(other.getClass()))
        return false;

      LanguageFormTranslation languageFormTranslation = (LanguageFormTranslation) other;

      return Objects.equals(getValue(), languageFormTranslation.getValue())
          && Objects.equals(getRange(), languageFormTranslation.getRange())
          && Objects.equals(getTranslationsByLanguageForm(), languageFormTranslation.getTranslationsByLanguageForm());
    }

    /**
     * A hash code for this object.
     *
     * @return a suitable hash code
     */
    @Override
    public int hashCode() {
      return Objects.hash(getValue(), getRange(), getTranslationsByLanguageForm());
    }

    /**
     * Gets the value for this per-language-form translation set.
     *
     * @return the value for this per-language-form translation set, not null
     */
    @Nonnull
    public Optional<String> getValue() {
      return Optional.ofNullable(value);
    }

    /**
     * Gets the range for this per-language-form translation set.
     *
     * @return the range for this per-language-form translation set, not null
     */
    @Nonnull
    public Optional<LanguageFormTranslationRange> getRange() {
      return Optional.ofNullable(range);
    }

    /**
     * Gets the translations by language form for this per-language-form translation set.
     *
     * @return the translations by language form for this per-language-form translation set, not null
     */
    @Nonnull
    public Map<LanguageForm, String> getTranslationsByLanguageForm() {
      return translationsByLanguageForm;
    }
  }

  /**
   * Container for per-language-form cardinality translation information over a range (start, end) of values.
   *
   * @author <a href="https://revetkn.com"><NAME></a>
   */
  @Immutable
  public static class LanguageFormTranslationRange {
    @Nonnull
    private String start;
    @Nonnull
    private String end;

    /**
     * Constructs a translation range with the given start and end values.
     *
     * @param start the start value of the range, not null
     * @param end   the end value of the range, not null
     */
    public LanguageFormTranslationRange(@Nonnull String start, @Nonnull String end) {
      requireNonNull(start);
      requireNonNull(end);

      this.start = start;
      this.end = end;
    }

    /**
     * Generates a {@code String} representation of this object.
     *
     * @return a string representation of this object, not null
     */
    @Override
    @Nonnull
    public String toString() {
      return format("%s{start=%s, end=%s", getClass().getSimpleName(), getStart(), getEnd());
    }

    /**
     * Checks if this object is equal to another one.
     *
     * @param other the object to check, null returns false
     * @return true if this is equal to the other object, false otherwise
     */
    @Override
    public boolean equals(@Nullable Object other) {
      if (this == other)
        return true;

      if (other == null || !getClass().equals(other.getClass()))
        return false;

      LanguageFormTranslationRange languageFormTranslationRange = (LanguageFormTranslationRange) other;

      return Objects.equals(getStart(), languageFormTranslationRange.getStart())
          && Objects.equals(getEnd(), languageFormTranslationRange.getEnd());
    }

    /**
     * A hash code for this object.
     *
     * @return a suitable hash code
     */
    @Override
    public int hashCode() {
      return Objects.hash(getStart(), getEnd());
    }

    /**
     * The start value for this range.
     *
     * @return the start value for this range, not null
     */
    @Nonnull
    public String getStart() {
      return start;
    }

    /**
     * The end value for this range.
     *
     * @return the end value for this range, not null
     */
    @Nonnull
    public String getEnd() {
      return end;
    }
  }
}

<|start_filename|>src/main/java/com/lokalized/Ordinality.java<|end_filename|>
/*
 * Copyright 2017 Product Mog LLC.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.lokalized;

import com.lokalized.Maps.MapEntry;

import javax.annotation.Nonnull;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.text.Collator;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.SortedMap;
import java.util.SortedSet;
import java.util.TreeSet;
import java.util.function.Function;
import java.util.stream.Collectors;

import static com.lokalized.NumberUtils.equal;
import static com.lokalized.NumberUtils.inRange;
import static com.lokalized.NumberUtils.inSet;
import static com.lokalized.NumberUtils.notEqual;
import static com.lokalized.NumberUtils.notInSet;
import static java.lang.String.format;
import static java.util.Objects.requireNonNull;

/**
 * Language plural ordinality forms.
 * <p>
 * For example, English has four: {@code 1st, 2nd, 3rd, 4th}, while Swedish has two: {@code 1:a, 3:e}.
 * <p>
 * See the <a href="http://cldr.unicode.org/index/cldr-spec/plural-rules">Unicode Common Locale Data Repository</a>
 * and its <a href="http://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html">Language Plural Rules</a> for details.
 * <p>
 * Per the CLDR:
 * <blockquote>
 * These categories are only mnemonics -- the names don't necessarily imply the exact contents of the category.
 * For example, for both English and French the number 1 has the category one (singular).
 * <p>
 * In English, every other number has a plural form, and is given the category other.
 * French is similar, except that the number 0 also has the category one and not other or zero, because the form of
 * units qualified by 0 is also singular.
 * <p>
 * This is worth emphasizing: A common mistake is to think that "one" is only for only the number 1.
 * Instead, "one" is a category for any number that behaves like 1. So in some languages, for example,
 * one → numbers that end in "1" (like 1, 21, 151) but that don't end in 11 (like "11, 111, 10311).
 * </blockquote>
 *
 * @author <a href="https://revetkn.com"><NAME></a>
 */
public enum Ordinality implements LanguageForm {
  /**
   * Normally the form used with 0, if it is limited to numbers whose integer values end with 0.
   * <p>
   * For example: the Welsh {@code 0fed ci} means "{@code 0th dog}" in English.
   */
  ZERO,
  /**
   * The form used with 1.
   * <p>
   * For example: the Welsh {@code ci 1af} means {@code 1st dog} in English.
   */
  ONE,
  /**
   * Normally the form used with 2, if it is limited to numbers whose integer values end with 2.
   * <p>
   * For example: the Welsh {@code 2il gi} means {@code 2nd dog} in English.
   */
  TWO,
  /**
   * The form that falls between {@code TWO} and {@code MANY}.
   * <p>
   * For example: the Welsh {@code 3ydd ci} means {@code 3rd dog} in English.
   */
  FEW,
  /**
   * The form that falls between {@code FEW} and {@code OTHER}.
   * <p>
   * For example: the Welsh {@code 5ed ci} means {@code 5th dog} in English.
   */
  MANY,
  /**
   * General "catchall" form which comprises any cases not handled by the other forms.
   * <p>
   * For example: the Welsh {@code ci rhif 10} means {@code 10th dog} in English.
   */
  OTHER;

  @Nonnull
  private static final BigInteger BIG_INTEGER_0;
  @Nonnull
  private static final BigInteger BIG_INTEGER_1;
  @Nonnull
  private static final BigInteger BIG_INTEGER_2;
  @Nonnull
  private static final BigInteger BIG_INTEGER_3;
  @Nonnull
  private static final BigInteger BIG_INTEGER_4;
  @Nonnull
  private static final BigInteger BIG_INTEGER_5;
  @Nonnull
  private static final BigInteger BIG_INTEGER_6;
  @Nonnull
  private static final BigInteger BIG_INTEGER_7;
  @Nonnull
  private static final BigInteger BIG_INTEGER_8;
  @Nonnull
  private static final BigInteger BIG_INTEGER_10;
  @Nonnull
  private static final BigInteger BIG_INTEGER_11;
  @Nonnull
  private static final BigInteger BIG_INTEGER_12;
  @Nonnull
  private static final BigInteger BIG_INTEGER_17;
  @Nonnull
  private static final BigInteger BIG_INTEGER_18;
  @Nonnull
  private static final BigInteger BIG_INTEGER_20;
  @Nonnull
  private static final BigInteger BIG_INTEGER_40;
  @Nonnull
  private static final BigInteger BIG_INTEGER_50;
  @Nonnull
  private static final BigInteger BIG_INTEGER_60;
  @Nonnull
  private static final BigInteger BIG_INTEGER_70;
  @Nonnull
  private static final BigInteger BIG_INTEGER_80;
  @Nonnull
  private static final BigInteger BIG_INTEGER_90;
  @Nonnull
  private static final BigInteger BIG_INTEGER_100;
  @Nonnull
  private static final BigInteger BIG_INTEGER_200;
  @Nonnull
  private static final BigInteger BIG_INTEGER_300;
  @Nonnull
  private static final BigInteger BIG_INTEGER_400;
  @Nonnull
  private static final BigInteger BIG_INTEGER_500;
  @Nonnull
  private static final BigInteger BIG_INTEGER_600;
  @Nonnull
  private static final BigInteger BIG_INTEGER_700;
  @Nonnull
  private static final BigInteger BIG_INTEGER_800;
  @Nonnull
  private static final BigInteger BIG_INTEGER_900;
  @Nonnull
  private static final BigInteger BIG_INTEGER_1_000;

  @Nonnull
  private static final BigDecimal BIG_DECIMAL_0;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_1;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_2;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_3;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_4;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_5;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_6;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_7;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_8;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_9;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_10;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_11;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_12;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_13;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_14;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_80;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_100;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_800;

  @Nonnull
  static final Map<String, Ordinality> ORDINALITIES_BY_NAME;

  static {
    BIG_INTEGER_0 = BigInteger.ZERO;
    BIG_INTEGER_1 = BigInteger.ONE;
    BIG_INTEGER_2 = BigInteger.valueOf(2);
    BIG_INTEGER_3 = BigInteger.valueOf(3);
    BIG_INTEGER_4 = BigInteger.valueOf(4);
    BIG_INTEGER_5 = BigInteger.valueOf(5);
    BIG_INTEGER_6 = BigInteger.valueOf(6);
    BIG_INTEGER_7 = BigInteger.valueOf(7);
    BIG_INTEGER_8 = BigInteger.valueOf(8);
    BIG_INTEGER_10 = BigInteger.TEN;
    BIG_INTEGER_11 = BigInteger.valueOf(11);
    BIG_INTEGER_12 = BigInteger.valueOf(12);
    BIG_INTEGER_17 = BigInteger.valueOf(17);
    BIG_INTEGER_18 = BigInteger.valueOf(18);
    BIG_INTEGER_20 = BigInteger.valueOf(20);
    BIG_INTEGER_40 = BigInteger.valueOf(40);
    BIG_INTEGER_50 = BigInteger.valueOf(50);
    BIG_INTEGER_60 = BigInteger.valueOf(60);
    BIG_INTEGER_70 = BigInteger.valueOf(70);
    BIG_INTEGER_80 = BigInteger.valueOf(80);
    BIG_INTEGER_90 = BigInteger.valueOf(90);
    BIG_INTEGER_100 = BigInteger.valueOf(100);
    BIG_INTEGER_200 = BigInteger.valueOf(200);
    BIG_INTEGER_300 = BigInteger.valueOf(300);
    BIG_INTEGER_400 = BigInteger.valueOf(400);
    BIG_INTEGER_500 = BigInteger.valueOf(500);
    BIG_INTEGER_600 = BigInteger.valueOf(600);
    BIG_INTEGER_700 = BigInteger.valueOf(700);
    BIG_INTEGER_800 = BigInteger.valueOf(800);
    BIG_INTEGER_900 = BigInteger.valueOf(900);
    BIG_INTEGER_1_000 = BigInteger.valueOf(1_000);

    BIG_DECIMAL_0 = BigDecimal.ZERO;
    BIG_DECIMAL_1 = BigDecimal.ONE;
    BIG_DECIMAL_2 = BigDecimal.valueOf(2);
    BIG_DECIMAL_3 = BigDecimal.valueOf(3);
    BIG_DECIMAL_4 = BigDecimal.valueOf(4);
    BIG_DECIMAL_5 = BigDecimal.valueOf(5);
    BIG_DECIMAL_6 = BigDecimal.valueOf(6);
    BIG_DECIMAL_7 = BigDecimal.valueOf(7);
    BIG_DECIMAL_8 = BigDecimal.valueOf(8);
    BIG_DECIMAL_9 = BigDecimal.valueOf(9);
    BIG_DECIMAL_10 = BigDecimal.TEN;
    BIG_DECIMAL_11 = BigDecimal.valueOf(11);
    BIG_DECIMAL_12 = BigDecimal.valueOf(12);
    BIG_DECIMAL_13 = BigDecimal.valueOf(13);
    BIG_DECIMAL_14 = BigDecimal.valueOf(14);
    BIG_DECIMAL_80 = BigDecimal.valueOf(80);
    BIG_DECIMAL_100 = BigDecimal.valueOf(100);
    BIG_DECIMAL_800 = BigDecimal.valueOf(800);

    ORDINALITIES_BY_NAME = Collections.unmodifiableMap(Arrays.stream(
        Ordinality.values()).collect(Collectors.toMap(ordinality -> ordinality.name(), ordinality -> ordinality)));
  }

  /**
   * Gets an appropriate plural ordinality for the given number and locale.
   * <p>
   * See <a href="http://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html">http://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html</a>
   * for a cheat sheet.
   *
   * @param number the number that drives pluralization, not null
   * @param locale the locale that drives pluralization, not null
   * @return an appropriate plural ordinality, not null
   * @throws UnsupportedLocaleException if the locale is not supported
   */
  @Nonnull
  public static Ordinality forNumber(@Nonnull Number number, @Nonnull Locale locale) {
    requireNonNull(number);
    requireNonNull(locale);

    BigDecimal numberAsBigDecimal = NumberUtils.toBigDecimal(number);

    Optional<OrdinalityFamily> ordinalityFamily = OrdinalityFamily.ordinalityFamilyForLocale(locale);

    // TODO: throwing an exception might not be the best solution here...need to think about it
    if (!ordinalityFamily.isPresent())
      throw new UnsupportedLocaleException(locale);

    return ordinalityFamily.get().getOrdinalityFunction().apply(numberAsBigDecimal);
  }

  /**
   * Gets the set of ordinalities supported for the given locale.
   * <p>
   * The empty set will be returned if the locale is not supported.
   * <p>
   * The set's values are sorted by the natural ordering of the {@link Ordinality} enumeration.
   *
   * @param locale the locale to use for lookup, not null
   * @return the ordinalities supported by the given locale, not null
   */
  @Nonnull
  public static SortedSet<Ordinality> supportedOrdinalitiesForLocale(@Nonnull Locale locale) {
    requireNonNull(locale);

    Optional<OrdinalityFamily> ordinalityFamily = OrdinalityFamily.ordinalityFamilyForLocale(locale);
    return ordinalityFamily.isPresent() ? ordinalityFamily.get().getSupportedOrdinalities() : Collections.emptySortedSet();
  }

  /**
   * Gets a mapping of ordinalities to example integer values for the given locale.
   * <p>
   * The empty map will be returned if the locale is not supported or if no example values are available.
   * <p>
   * The map's keys are sorted by the natural ordering of the {@link Ordinality} enumeration.
   *
   * @param locale the locale to use for lookup, not null
   * @return a mapping of ordinalities to example integer values, not null
   */
  @Nonnull
  public static SortedMap<Ordinality, Range<Integer>> exampleIntegerValuesForLocale(@Nonnull Locale locale) {
    requireNonNull(locale);

    Optional<OrdinalityFamily> ordinalityFamily = OrdinalityFamily.ordinalityFamilyForLocale(locale);
    return ordinalityFamily.isPresent() ? ordinalityFamily.get().getExampleIntegerValuesByOrdinality() : Collections.emptySortedMap();
  }

  /**
   * Gets the ISO 639 language codes for which ordinality operations are supported.
   * <p>
   * The set's values are ISO 639 codes and therefore sorted using English collation.
   *
   * @return the ISO 639 language codes for which ordinality operations are supported, not null
   */
  @Nonnull
  public static SortedSet<String> getSupportedLanguageCodes() {
    return OrdinalityFamily.getSupportedLanguageCodes();
  }

  /**
   * Gets the mapping of ordinality names to values.
   *
   * @return the mapping of ordinality names to values, not null
   */
  @Nonnull
  static Map<String, Ordinality> getOrdinalitiesByName() {
    return ORDINALITIES_BY_NAME;
  }

  /**
   * Plural ordinality forms grouped by language family.
   * <p>
   * Each family has a distinct ordinality calculation rule.
   * <p>
   * See <a href="http://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html">http://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html</a>
   * for a cheat sheet.
   */
  enum OrdinalityFamily {
    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Afrikaans (af)</li>
     * <li>Akan (ak) (no CLDR data available)</li>
     * <li>Amharic (am)</li>
     * <li>Arabic (ar)</li>
     * <li>Najdi Arabic (ars) (no CLDR data available)</li>
     * <li>Asu (asa) (no CLDR data available)</li>
     * <li>Asturian (ast) (no CLDR data available)</li>
     * <li>Bemba (bem) (no CLDR data available)</li>
     * <li>Bena (bez) (no CLDR data available)</li>
     * <li>Bulgarian (bg)</li>
     * <li>Bihari (bh) (no CLDR data available)</li>
     * <li>Bambara (bm) (no CLDR data available)</li>
     * <li>Tibetan (bo) (no CLDR data available)</li>
     * <li>Breton (br) (no CLDR data available)</li>
     * <li>Bodo (brx) (no CLDR data available)</li>
     * <li>Bosnian (bs)</li>
     * <li>Chechen (ce)</li>
     * <li>Chiga (cgg) (no CLDR data available)</li>
     * <li>Cherokee (chr) (no CLDR data available)</li>
     * <li>Central Kurdish (ckb) (no CLDR data available)</li>
     * <li>Czech (cs)</li>
     * <li>Danish (da)</li>
     * <li>German (de)</li>
     * <li>Lower Sorbian (dsb)</li>
     * <li>Divehi (dv) (no CLDR data available)</li>
     * <li>Dzongkha (dz) (no CLDR data available)</li>
     * <li>Ewe (ee) (no CLDR data available)</li>
     * <li>Greek (el)</li>
     * <li>Esperanto (eo) (no CLDR data available)</li>
     * <li>Spanish (es)</li>
     * <li>Estonian (et)</li>
     * <li>Basque (eu)</li>
     * <li>Persian (fa)</li>
     * <li>Fulah (ff) (no CLDR data available)</li>
     * <li>Finnish (fi)</li>
     * <li>Faroese (fo) (no CLDR data available)</li>
     * <li>Friulian (fur) (no CLDR data available)</li>
     * <li>Western Frisian (fy)</li>
     * <li>Scottish Gaelic (gd) (no CLDR data available)</li>
     * <li>Galician (gl)</li>
     * <li>Swiss German (gsw)</li>
     * <li>Gun (guw) (no CLDR data available)</li>
     * <li>Manx (gv) (no CLDR data available)</li>
     * <li>Hausa (ha) (no CLDR data available)</li>
     * <li>Hawaiian (haw) (no CLDR data available)</li>
     * <li>Hebrew (he)</li>
     * <li>Croatian (hr)</li>
     * <li>Upper Sorbian (hsb)</li>
     * <li>Indonesian (id)</li>
     * <li>Igbo (ig) (no CLDR data available)</li>
     * <li>Sichuan Yi (ii) (no CLDR data available)</li>
     * <li>Icelandic (is)</li>
     * <li>Inuktitut (iu) (no CLDR data available)</li>
     * <li>Japanese (ja)</li>
     * <li>Lojban (jbo) (no CLDR data available)</li>
     * <li>Ngomba (jgo) (no CLDR data available)</li>
     * <li>Machame (jmc) (no CLDR data available)</li>
     * <li>Javanese (jv) (no CLDR data available)</li>
     * <li>Javanese (jw) (no CLDR data available)</li>
     * <li>Kabyle (kab) (no CLDR data available)</li>
     * <li>Jju (kaj) (no CLDR data available)</li>
     * <li>Tyap (kcg) (no CLDR data available)</li>
     * <li>Makonde (kde) (no CLDR data available)</li>
     * <li>Kabuverdianu (kea) (no CLDR data available)</li>
     * <li>Kako (kkj) (no CLDR data available)</li>
     * <li>Greenlandic (kl) (no CLDR data available)</li>
     * <li>Khmer (km)</li>
     * <li>Kannada (kn)</li>
     * <li>Korean (ko)</li>
     * <li>Kashmiri (ks) (no CLDR data available)</li>
     * <li>Shambala (ksb) (no CLDR data available)</li>
     * <li>Colognian (ksh) (no CLDR data available)</li>
     * <li>Kurdish (ku) (no CLDR data available)</li>
     * <li>Cornish (kw) (no CLDR data available)</li>
     * <li>Kirghiz (ky)</li>
     * <li>Langi (lag) (no CLDR data available)</li>
     * <li>Luxembourgish (lb) (no CLDR data available)</li>
     * <li>Ganda (lg) (no CLDR data available)</li>
     * <li>Lakota (lkt) (no CLDR data available)</li>
     * <li>Lingala (ln) (no CLDR data available)</li>
     * <li>Lithuanian (lt)</li>
     * <li>Latvian (lv)</li>
     * <li>Masai (mas) (no CLDR data available)</li>
     * <li>Malagasy (mg) (no CLDR data available)</li>
     * <li>Metaʼ (mgo) (no CLDR data available)</li>
     * <li>Malayalam (ml)</li>
     * <li>Mongolian (mn)</li>
     * <li>Maltese (mt) (no CLDR data available)</li>
     * <li>Burmese (my)</li>
     * <li>Nahuatl (nah) (no CLDR data available)</li>
     * <li>Nama (naq) (no CLDR data available)</li>
     * <li>Norwegian Bokmål (nb)</li>
     * <li>North Ndebele (nd) (no CLDR data available)</li>
     * <li>Dutch (nl)</li>
     * <li>Norwegian Nynorsk (nn) (no CLDR data available)</li>
     * <li>Ngiemboon (nnh) (no CLDR data available)</li>
     * <li>Norwegian (no) (no CLDR data available)</li>
     * <li>N’Ko (nqo) (no CLDR data available)</li>
     * <li>South Ndebele (nr) (no CLDR data available)</li>
     * <li>Northern Sotho (nso) (no CLDR data available)</li>
     * <li>Nyanja (ny) (no CLDR data available)</li>
     * <li>Nyankole (nyn) (no CLDR data available)</li>
     * <li>Oromo (om) (no CLDR data available)</li>
     * <li>Odia (or) (no CLDR data available)</li>
     * <li>Ossetian (os) (no CLDR data available)</li>
     * <li>Punjabi (pa)</li>
     * <li>Papiamento (pap) (no CLDR data available)</li>
     * <li>Polish (pl)</li>
     * <li>Prussian (prg)</li>
     * <li>Pushto (ps) (no CLDR data available)</li>
     * <li>Portuguese (pt)</li>
     * <li>Romansh (rm) (no CLDR data available)</li>
     * <li>Rombo (rof) (no CLDR data available)</li>
     * <li>Root (root)</li>
     * <li>Russian (ru)</li>
     * <li>Rwa (rwk) (no CLDR data available)</li>
     * <li>Sakha (sah) (no CLDR data available)</li>
     * <li>Samburu (saq) (no CLDR data available)</li>
     * <li>Southern Kurdish (sdh) (no CLDR data available)</li>
     * <li>Northern Sami (se) (no CLDR data available)</li>
     * <li>Sena (seh) (no CLDR data available)</li>
     * <li>Koyraboro Senni (ses) (no CLDR data available)</li>
     * <li>Sango (sg) (no CLDR data available)</li>
     * <li>Serbo-Croatian (sh)</li>
     * <li>Tachelhit (shi) (no CLDR data available)</li>
     * <li>Sinhalese (si)</li>
     * <li>Slovak (sk)</li>
     * <li>Slovenian (sl)</li>
     * <li>Southern Sami (sma) (no CLDR data available)</li>
     * <li>Sami (smi) (no CLDR data available)</li>
     * <li>Lule Sami (smj) (no CLDR data available)</li>
     * <li>Inari Sami (smn) (no CLDR data available)</li>
     * <li>Skolt Sami (sms) (no CLDR data available)</li>
     * <li>Shona (sn) (no CLDR data available)</li>
     * <li>Somali (so) (no CLDR data available)</li>
     * <li>Serbian (sr)</li>
     * <li>Swati (ss) (no CLDR data available)</li>
     * <li>Saho (ssy) (no CLDR data available)</li>
     * <li>Southern Sotho (st) (no CLDR data available)</li>
     * <li>Swahili (sw)</li>
     * <li>Syriac (syr) (no CLDR data available)</li>
     * <li>Tamil (ta)</li>
     * <li>Telugu (te)</li>
     * <li>Teso (teo) (no CLDR data available)</li>
     * <li>Thai (th)</li>
     * <li>Tigrinya (ti) (no CLDR data available)</li>
     * <li>Tigre (tig) (no CLDR data available)</li>
     * <li>Turkmen (tk) (no CLDR data available)</li>
     * <li>Tswana (tn) (no CLDR data available)</li>
     * <li>Tongan (to) (no CLDR data available)</li>
     * <li>Turkish (tr)</li>
     * <li>Tsonga (ts) (no CLDR data available)</li>
     * <li>Central Atlas Tamazight (tzm) (no CLDR data available)</li>
     * <li>Uighur (ug) (no CLDR data available)</li>
     * <li>Urdu (ur)</li>
     * <li>Uzbek (uz)</li>
     * <li>Venda (ve) (no CLDR data available)</li>
     * <li>Volapük (vo) (no CLDR data available)</li>
     * <li>Vunjo (vun) (no CLDR data available)</li>
     * <li>Walloon (wa) (no CLDR data available)</li>
     * <li>Walser (wae) (no CLDR data available)</li>
     * <li>Wolof (wo) (no CLDR data available)</li>
     * <li>Xhosa (xh) (no CLDR data available)</li>
     * <li>Soga (xog) (no CLDR data available)</li>
     * <li>Yiddish (yi) (no CLDR data available)</li>
     * <li>Yoruba (yo) (no CLDR data available)</li>
     * <li>Cantonese (yue)</li>
     * <li>Mandarin Chinese (zh)</li>
     * <li>Zulu (zu)</li>
     * </ul>
     */
    FAMILY_1(
        (n) -> {
          // No ordinality rules for this family
          return OTHER;
        },
        Sets.sortedSet(
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Ordinality.OTHER, Range.ofInfiniteValues(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 100, 1000, 10000, 100000, 1000000))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Filipino (fil)</li>
     * <li>French (fr)</li>
     * <li>Irish (ga)</li>
     * <li>Armenian (hy)</li>
     * <li>Lao (lo)</li>
     * <li>Moldovan (mo)</li>
     * <li>Malay (ms)</li>
     * <li>Romanian (ro)</li>
     * <li>Tagalog (tl)</li>
     * <li>Vietnamese (vi)</li>
     * </ul>
     */
    FAMILY_2(
        (n) -> {
          // n = 1
          if (equal(n, BIG_DECIMAL_1))
            return ONE;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Ordinality.ONE, Range.ofFiniteValues(1)),
            MapEntry.of(Ordinality.OTHER, Range.ofInfiniteValues(0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 100, 1000, 10000, 100000, 1000000))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Assamese (as)</li>
     * <li>Bangla (bn)</li>
     * </ul>
     */
    FAMILY_3(
        (n) -> {
          // n = 1,5,7,8,9,10
          if (inSet(n, BIG_DECIMAL_1, BIG_DECIMAL_5, BIG_DECIMAL_7, BIG_DECIMAL_8, BIG_DECIMAL_9, BIG_DECIMAL_10))
            return ONE;
          // n = 2,3
          if (inSet(n, BIG_DECIMAL_2, BIG_DECIMAL_3))
            return TWO;
          // n = 4
          if (equal(n, BIG_DECIMAL_4))
            return FEW;
          // n = 6
          if (equal(n, BIG_DECIMAL_6))
            return MANY;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            TWO,
            FEW,
            MANY,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Ordinality.ONE, Range.ofFiniteValues(1, 5, 7, 8, 9, 10)),
            MapEntry.of(Ordinality.TWO, Range.ofFiniteValues(2, 3)),
            MapEntry.of(Ordinality.FEW, Range.ofFiniteValues(4)),
            MapEntry.of(Ordinality.MANY, Range.ofFiniteValues(6)),
            MapEntry.of(Ordinality.OTHER, Range.ofInfiniteValues(0, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 100, 1000, 10000, 100000, 1000000))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Gujarati (gu)</li>
     * <li>Hindi (hi)</li>
     * </ul>
     */
    FAMILY_4(
        (n) -> {
          // n = 1
          if (equal(n, BIG_DECIMAL_1))
            return ONE;
          // n = 2,3
          if (inSet(n, BIG_DECIMAL_2, BIG_DECIMAL_3))
            return TWO;
          // n = 4
          if (equal(n, BIG_DECIMAL_4))
            return FEW;
          // n = 6
          if (equal(n, BIG_DECIMAL_6))
            return MANY;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            TWO,
            FEW,
            MANY,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Ordinality.ONE, Range.ofFiniteValues(1)),
            MapEntry.of(Ordinality.TWO, Range.ofFiniteValues(2, 3)),
            MapEntry.of(Ordinality.FEW, Range.ofFiniteValues(4)),
            MapEntry.of(Ordinality.MANY, Range.ofFiniteValues(6)),
            MapEntry.of(Ordinality.OTHER, Range.ofInfiniteValues(0, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 100, 1000, 10000, 100000, 1000000))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Azeri (az)</li>
     * </ul>
     */
    FAMILY_5(
        (n) -> {
          BigInteger i = NumberUtils.integerComponent(n);

          // i % 10 = 1,2,5,7,8 or i % 100 = 20,50,70,80
          if (inSet(i.mod(BIG_INTEGER_10), BIG_INTEGER_1, BIG_INTEGER_2, BIG_INTEGER_5, BIG_INTEGER_7, BIG_INTEGER_8)
              || inSet(i.mod(BIG_INTEGER_100), BIG_INTEGER_20, BIG_INTEGER_50, BIG_INTEGER_70, BIG_INTEGER_80))
            return ONE;
          // i % 10 = 3,4 or i % 1000 = 100,200,300,400,500,600,700,800,900
          if (inSet(i.mod(BIG_INTEGER_10), BIG_INTEGER_3, BIG_INTEGER_4)
              || inSet(i.mod(BIG_INTEGER_1_000), BIG_INTEGER_100, BIG_INTEGER_200, BIG_INTEGER_300, BIG_INTEGER_400, BIG_INTEGER_500, BIG_INTEGER_600, BIG_INTEGER_700, BIG_INTEGER_800, BIG_INTEGER_900))
            return FEW;
          // i = 0 or i % 10 = 6 or i % 100 = 40,60,90
          if (equal(i, BIG_INTEGER_0)
              || equal(i.mod(BIG_INTEGER_10), BIG_INTEGER_6)
              || inSet(i.mod(BIG_INTEGER_100), BIG_INTEGER_40, BIG_INTEGER_60, BIG_INTEGER_90))
            return MANY;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            FEW,
            MANY,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Ordinality.ONE, Range.ofInfiniteValues(1, 2, 5, 7, 8, 11, 12, 15, 17, 18, 20, 21, 22, 25, 101, 1001)),
            MapEntry.of(Ordinality.FEW, Range.ofInfiniteValues(3, 4, 13, 14, 23, 24, 33, 34, 43, 44, 53, 54, 63, 64, 73, 74, 100, 1003)),
            MapEntry.of(Ordinality.MANY, Range.ofInfiniteValues(0, 6, 16, 26, 36, 40, 46, 56, 106, 1006)),
            MapEntry.of(Ordinality.OTHER, Range.ofInfiniteValues(9, 10, 19, 29, 30, 39, 49, 59, 69, 79, 109, 1000, 10000, 100000, 1000000))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Belarusian (be)</li>
     * </ul>
     */
    FAMILY_6(
        (n) -> {
          // n % 10 = 2,3 and n % 100 != 12,13
          if (inSet(n.remainder(BIG_DECIMAL_10), BIG_DECIMAL_2, BIG_DECIMAL_3)
              && notInSet(n.remainder(BIG_DECIMAL_100), BIG_DECIMAL_12, BIG_DECIMAL_13))
            return FEW;

          return OTHER;
        },
        Sets.sortedSet(
            FEW,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Ordinality.FEW, Range.ofInfiniteValues(2, 3, 22, 23, 32, 33, 42, 43, 52, 53, 62, 63, 72, 73, 82, 83, 102, 1002)),
            MapEntry.of(Ordinality.OTHER, Range.ofInfiniteValues(0, 1, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 100, 1000, 10000, 100000, 1000000))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Catalan (ca)</li>
     * </ul>
     */
    FAMILY_7(
        (n) -> {
          // n = 1,3
          if (inSet(n, BIG_DECIMAL_1, BIG_DECIMAL_3))
            return ONE;
          // n = 2
          if (equal(n, BIG_DECIMAL_2))
            return TWO;
          // n = 4
          if (equal(n, BIG_DECIMAL_4))
            return FEW;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            TWO,
            FEW,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Ordinality.ONE, Range.ofFiniteValues(1, 3)),
            MapEntry.of(Ordinality.TWO, Range.ofFiniteValues(2)),
            MapEntry.of(Ordinality.FEW, Range.ofFiniteValues(4)),
            MapEntry.of(Ordinality.OTHER, Range.ofInfiniteValues(0, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 100, 1000, 10000, 100000, 1000000))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Welsh (cy)</li>
     * </ul>
     */
    FAMILY_8(
        (n) -> {
          // n = 0,7,8,9
          if (inSet(n, BIG_DECIMAL_0, BIG_DECIMAL_7, BIG_DECIMAL_8, BIG_DECIMAL_9))
            return ZERO;
          // n = 1
          if (equal(n, BIG_DECIMAL_1))
            return ONE;
          // n = 2
          if (equal(n, BIG_DECIMAL_2))
            return TWO;
          // n = 3,4
          if (inSet(n, BIG_DECIMAL_3, BIG_DECIMAL_4))
            return FEW;
          // n = 5,6
          if (inSet(n, BIG_DECIMAL_5, BIG_DECIMAL_6))
            return MANY;

          return OTHER;
        },
        Sets.sortedSet(
            ZERO,
            ONE,
            TWO,
            FEW,
            MANY,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Ordinality.ZERO, Range.ofFiniteValues(0, 7, 8, 9)),
            MapEntry.of(Ordinality.ONE, Range.ofFiniteValues(1)),
            MapEntry.of(Ordinality.TWO, Range.ofFiniteValues(2)),
            MapEntry.of(Ordinality.FEW, Range.ofFiniteValues(3, 4)),
            MapEntry.of(Ordinality.MANY, Range.ofFiniteValues(5, 6)),
            MapEntry.of(Ordinality.OTHER, Range.ofInfiniteValues(10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 100, 1000, 10000, 100000, 1000000))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>English (en)</li>
     * </ul>
     */
    FAMILY_9(
        (n) -> {
          // n % 10 = 1 and n % 100 != 11
          if (equal(n.remainder(BIG_DECIMAL_10), BIG_DECIMAL_1) && notEqual(n.remainder(BIG_DECIMAL_100), BIG_DECIMAL_11))
            return ONE;
          // n % 10 = 2 and n % 100 != 12
          if (equal(n.remainder(BIG_DECIMAL_10), BIG_DECIMAL_2) && notEqual(n.remainder(BIG_DECIMAL_100), BIG_DECIMAL_12))
            return TWO;
          // n % 10 = 3 and n % 100 != 13
          if (equal(n.remainder(BIG_DECIMAL_10), BIG_DECIMAL_3) && notEqual(n.remainder(BIG_DECIMAL_100), BIG_DECIMAL_13))
            return FEW;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            TWO,
            FEW,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Ordinality.ONE, Range.ofInfiniteValues(1, 21, 31, 41, 51, 61, 71, 81, 101, 1001)),
            MapEntry.of(Ordinality.TWO, Range.ofInfiniteValues(2, 22, 32, 42, 52, 62, 72, 82, 102, 1002)),
            MapEntry.of(Ordinality.FEW, Range.ofInfiniteValues(3, 23, 33, 43, 53, 63, 73, 83, 103, 1003)),
            MapEntry.of(Ordinality.OTHER, Range.ofInfiniteValues(0, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 100, 1000, 10000, 100000, 1000000))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Hungarian (hu)</li>
     * </ul>
     */
    FAMILY_10(
        (n) -> {
          // n = 1,5
          if (inSet(n, BIG_DECIMAL_1, BIG_DECIMAL_5))
            return ONE;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Ordinality.ONE, Range.ofFiniteValues(1, 5)),
            MapEntry.of(Ordinality.OTHER, Range.ofInfiniteValues(0, 2, 3, 4, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 100, 1000, 10000, 100000, 1000000))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Italian (it)</li>
     * </ul>
     */
    FAMILY_11(
        (n) -> {
          // n = 11,8,80,800
          if (inSet(n, BIG_DECIMAL_11, BIG_DECIMAL_8, BIG_DECIMAL_80, BIG_DECIMAL_800))
            return MANY;

          return OTHER;
        },
        Sets.sortedSet(
            MANY,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Ordinality.MANY, Range.ofFiniteValues(8, 11, 80, 800)),
            MapEntry.of(Ordinality.OTHER, Range.ofInfiniteValues(0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 12, 13, 14, 15, 16, 17, 100, 1000, 10000, 100000, 1000000))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Georgian (ka)</li>
     * </ul>
     */
    FAMILY_12(
        (n) -> {
          BigInteger i = NumberUtils.integerComponent(n);

          // i = 1
          if (equal(i, BIG_INTEGER_1))
            return ONE;
          // i = 0 or i % 100 = 2..20,40,60,80
          if (equal(i, BIG_INTEGER_0) || inRange(i.mod(BIG_INTEGER_100), BIG_INTEGER_2, BIG_INTEGER_20) || inSet(i.mod(BIG_INTEGER_100), BIG_INTEGER_40, BIG_INTEGER_60, BIG_INTEGER_80))
            return MANY;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            MANY,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Ordinality.ONE, Range.ofFiniteValues(1)),
            MapEntry.of(Ordinality.MANY, Range.ofInfiniteValues(0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 102, 1002)),
            MapEntry.of(Ordinality.OTHER, Range.ofInfiniteValues(21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 100, 1000, 10000, 100000, 1000000))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Kazakh (kk)</li>
     * </ul>
     */
    FAMILY_13(
        (n) -> {
          // n % 10 = 6 or n % 10 = 9 or n % 10 = 0 and n != 0
          if (equal(n.remainder(BIG_DECIMAL_10), BIG_DECIMAL_6)
              || equal(n.remainder(BIG_DECIMAL_10), BIG_DECIMAL_9)
              || (equal(n.remainder(BIG_DECIMAL_10), BIG_DECIMAL_0) && notEqual(n, BIG_DECIMAL_0)))
            return MANY;

          return OTHER;
        },
        Sets.sortedSet(
            MANY,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Ordinality.MANY, Range.ofInfiniteValues(6, 9, 10, 16, 19, 20, 26, 29, 30, 36, 39, 40, 100, 1000, 10000, 100000, 1000000)),
            MapEntry.of(Ordinality.OTHER, Range.ofInfiniteValues(0, 1, 2, 3, 4, 5, 7, 8, 11, 12, 13, 14, 15, 17, 18, 21, 101, 1001))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Macedonian (mk)</li>
     * </ul>
     */
    FAMILY_14(
        (n) -> {
          BigInteger i = NumberUtils.integerComponent(n);

          // i % 10 = 1 and i % 100 != 11
          if (equal(i.mod(BIG_INTEGER_10), BIG_INTEGER_1) && notEqual(i.mod(BIG_INTEGER_100), BIG_INTEGER_11))
            return ONE;
          // i % 10 = 2 and i % 100 != 12
          if (equal(i.mod(BIG_INTEGER_10), BIG_INTEGER_2) && notEqual(i.mod(BIG_INTEGER_100), BIG_INTEGER_12))
            return TWO;
          // i % 10 = 7,8 and i % 100 != 17,18
          if (inSet(i.mod(BIG_INTEGER_10), BIG_INTEGER_7, BIG_INTEGER_8) && notInSet(i.mod(BIG_INTEGER_100), BIG_INTEGER_17, BIG_INTEGER_18))
            return MANY;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            TWO,
            MANY,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Ordinality.ONE, Range.ofInfiniteValues(1, 21, 31, 41, 51, 61, 71, 81, 101, 1001)),
            MapEntry.of(Ordinality.TWO, Range.ofInfiniteValues(2, 22, 32, 42, 52, 62, 72, 82, 102, 1002)),
            MapEntry.of(Ordinality.MANY, Range.ofInfiniteValues(7, 8, 27, 28, 37, 38, 47, 48, 57, 58, 67, 68, 77, 78, 87, 88, 107, 1007)),
            MapEntry.of(Ordinality.OTHER, Range.ofInfiniteValues(0, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 100, 1000, 10000, 100000, 1000000))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Marathi (mr)</li>
     * </ul>
     */
    FAMILY_15(
        (n) -> {
          // n = 1
          if (equal(n, BIG_DECIMAL_1))
            return ONE;
          // n = 2,3
          if (inSet(n, BIG_DECIMAL_2, BIG_DECIMAL_3))
            return TWO;
          // n = 4
          if (equal(n, BIG_DECIMAL_4))
            return FEW;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            TWO,
            FEW,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Ordinality.ONE, Range.ofFiniteValues(1)),
            MapEntry.of(Ordinality.TWO, Range.ofFiniteValues(2, 3)),
            MapEntry.of(Ordinality.FEW, Range.ofFiniteValues(4)),
            MapEntry.of(Ordinality.OTHER, Range.ofInfiniteValues(0, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 100, 1000, 10000, 100000, 1000000))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Nepali (ne)</li>
     * </ul>
     */
    FAMILY_16(
        (n) -> {
          // n = 1..4
          if (inRange(n, BIG_DECIMAL_1, BIG_DECIMAL_4))
            return ONE;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Ordinality.ONE, Range.ofFiniteValues(1, 2, 3, 4)),
            MapEntry.of(Ordinality.OTHER, Range.ofInfiniteValues(0, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 100, 1000, 10000, 100000, 1000000))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Albanian (sq)</li>
     * </ul>
     */
    FAMILY_17(
        (n) -> {
          // n = 1
          if (equal(n, BIG_DECIMAL_1))
            return ONE;
          // n % 10 = 4 and n % 100 != 14
          if (equal(n.remainder(BIG_DECIMAL_10), BIG_DECIMAL_4) && notEqual(n.remainder(BIG_DECIMAL_100), BIG_DECIMAL_14))
            return MANY;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            MANY,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Ordinality.ONE, Range.ofFiniteValues(1)),
            MapEntry.of(Ordinality.MANY, Range.ofInfiniteValues(4, 24, 34, 44, 54, 64, 74, 84, 104, 1004)),
            MapEntry.of(Ordinality.OTHER, Range.ofInfiniteValues(0, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 100, 1000, 10000, 100000, 1000000))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Swedish (sv)</li>
     * </ul>
     */
    FAMILY_18(
        (n) -> {
          // n % 10 = 1,2 and n % 100 != 11,12
          if (inSet(n.remainder(BIG_DECIMAL_10), BIG_DECIMAL_1, BIG_DECIMAL_2) && notInSet(n.remainder(BIG_DECIMAL_100), BIG_DECIMAL_11, BIG_DECIMAL_12))
            return ONE;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Ordinality.ONE, Range.ofInfiniteValues(1, 2, 21, 22, 31, 32, 41, 42, 51, 52, 61, 62, 71, 72, 81, 82, 101, 1001)),
            MapEntry.of(Ordinality.OTHER, Range.ofInfiniteValues(0, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 100, 1000, 10000, 100000, 1000000))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Ukrainian (uk)</li>
     * </ul>
     */
    FAMILY_19(
        (n) -> {
          // n % 10 = 3 and n % 100 != 13
          if (equal(n.remainder(BIG_DECIMAL_10), BIG_DECIMAL_3) && notEqual(n.remainder(BIG_DECIMAL_100), BIG_DECIMAL_13))
            return FEW;

          return OTHER;
        },
        Sets.sortedSet(
            FEW,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Ordinality.FEW, Range.ofInfiniteValues(3, 23, 33, 43, 53, 63, 73, 83, 103, 1003)),
            MapEntry.of(Ordinality.OTHER, Range.ofInfiniteValues(0, 1, 2, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 100, 1000, 10000, 100000, 1000000))
        )
    );

    @Nonnull
    private static final Map<String, OrdinalityFamily> ORDINALITY_FAMILIES_BY_LANGUAGE_CODE;
    @Nonnull
    private static final SortedSet<String> SUPPORTED_LANGUAGE_CODES;

    @Nonnull
    private final Function<BigDecimal, Ordinality> ordinalityFunction;
    @Nonnull
    private final SortedSet<Ordinality> supportedOrdinalities;
    @Nonnull
    private final SortedMap<Ordinality, Range<Integer>> exampleIntegerValuesByOrdinality;

    /**
     * Constructs an ordinality family.
     *
     * @param ordinalityFunction               the ordinality-determining function for this ordinality family, not null
     * @param supportedOrdinalities            the ordinalities supported by this family sorted by the natural ordering of {@link Ordinality}, not null
     * @param exampleIntegerValuesByOrdinality a mapping of ordinalities to example integer values for this ordinality family sorted by the natural ordering of {@link Ordinality}, not null
     */
    OrdinalityFamily(@Nonnull Function<BigDecimal, Ordinality> ordinalityFunction,
                     @Nonnull SortedSet<Ordinality> supportedOrdinalities,
                     @Nonnull SortedMap<Ordinality, Range<Integer>> exampleIntegerValuesByOrdinality) {
      requireNonNull(ordinalityFunction);
      requireNonNull(supportedOrdinalities);
      requireNonNull(exampleIntegerValuesByOrdinality);

      this.ordinalityFunction = ordinalityFunction;
      this.supportedOrdinalities = supportedOrdinalities;
      this.exampleIntegerValuesByOrdinality = exampleIntegerValuesByOrdinality;
    }

    static {
      ORDINALITY_FAMILIES_BY_LANGUAGE_CODE = Collections.unmodifiableMap(new HashMap<String, OrdinalityFamily>() {{
        put("af", OrdinalityFamily.FAMILY_1); // Afrikaans
        put("ak", OrdinalityFamily.FAMILY_1); // Akan (no CLDR data available)
        put("am", OrdinalityFamily.FAMILY_1); // Amharic
        put("ar", OrdinalityFamily.FAMILY_1); // Arabic
        put("ars", OrdinalityFamily.FAMILY_1); // Najdi Arabic (no CLDR data available)
        put("as", OrdinalityFamily.FAMILY_3); // Assamese
        put("asa", OrdinalityFamily.FAMILY_1); // Asu (no CLDR data available)
        put("ast", OrdinalityFamily.FAMILY_1); // Asturian (no CLDR data available)
        put("az", OrdinalityFamily.FAMILY_5); // Azeri
        put("be", OrdinalityFamily.FAMILY_6); // Belarusian
        put("bem", OrdinalityFamily.FAMILY_1); // Bemba (no CLDR data available)
        put("bez", OrdinalityFamily.FAMILY_1); // Bena (no CLDR data available)
        put("bg", OrdinalityFamily.FAMILY_1); // Bulgarian
        put("bh", OrdinalityFamily.FAMILY_1); // Bihari (no CLDR data available)
        put("bm", OrdinalityFamily.FAMILY_1); // Bambara (no CLDR data available)
        put("bn", OrdinalityFamily.FAMILY_3); // Bangla
        put("bo", OrdinalityFamily.FAMILY_1); // Tibetan (no CLDR data available)
        put("br", OrdinalityFamily.FAMILY_1); // Breton (no CLDR data available)
        put("brx", OrdinalityFamily.FAMILY_1); // Bodo (no CLDR data available)
        put("bs", OrdinalityFamily.FAMILY_1); // Bosnian
        put("ca", OrdinalityFamily.FAMILY_7); // Catalan
        put("ce", OrdinalityFamily.FAMILY_1); // Chechen
        put("cgg", OrdinalityFamily.FAMILY_1); // Chiga (no CLDR data available)
        put("chr", OrdinalityFamily.FAMILY_1); // Cherokee (no CLDR data available)
        put("ckb", OrdinalityFamily.FAMILY_1); // Central Kurdish (no CLDR data available)
        put("cs", OrdinalityFamily.FAMILY_1); // Czech
        put("cy", OrdinalityFamily.FAMILY_8); // Welsh
        put("da", OrdinalityFamily.FAMILY_1); // Danish
        put("de", OrdinalityFamily.FAMILY_1); // German
        put("dsb", OrdinalityFamily.FAMILY_1); // Lower Sorbian
        put("dv", OrdinalityFamily.FAMILY_1); // Divehi (no CLDR data available)
        put("dz", OrdinalityFamily.FAMILY_1); // Dzongkha (no CLDR data available)
        put("ee", OrdinalityFamily.FAMILY_1); // Ewe (no CLDR data available)
        put("el", OrdinalityFamily.FAMILY_1); // Greek
        put("en", OrdinalityFamily.FAMILY_9); // English
        put("eo", OrdinalityFamily.FAMILY_1); // Esperanto (no CLDR data available)
        put("es", OrdinalityFamily.FAMILY_1); // Spanish
        put("et", OrdinalityFamily.FAMILY_1); // Estonian
        put("eu", OrdinalityFamily.FAMILY_1); // Basque
        put("fa", OrdinalityFamily.FAMILY_1); // Persian
        put("ff", OrdinalityFamily.FAMILY_1); // Fulah (no CLDR data available)
        put("fi", OrdinalityFamily.FAMILY_1); // Finnish
        put("fil", OrdinalityFamily.FAMILY_2); // Filipino
        put("fo", OrdinalityFamily.FAMILY_1); // Faroese (no CLDR data available)
        put("fr", OrdinalityFamily.FAMILY_2); // French
        put("fur", OrdinalityFamily.FAMILY_1); // Friulian (no CLDR data available)
        put("fy", OrdinalityFamily.FAMILY_1); // Western Frisian
        put("ga", OrdinalityFamily.FAMILY_2); // Irish
        put("gd", OrdinalityFamily.FAMILY_1); // Scottish Gaelic (no CLDR data available)
        put("gl", OrdinalityFamily.FAMILY_1); // Galician
        put("gsw", OrdinalityFamily.FAMILY_1); // Swiss German
        put("gu", OrdinalityFamily.FAMILY_4); // Gujarati
        put("guw", OrdinalityFamily.FAMILY_1); // Gun (no CLDR data available)
        put("gv", OrdinalityFamily.FAMILY_1); // Manx (no CLDR data available)
        put("ha", OrdinalityFamily.FAMILY_1); // Hausa (no CLDR data available)
        put("haw", OrdinalityFamily.FAMILY_1); // Hawaiian (no CLDR data available)
        put("he", OrdinalityFamily.FAMILY_1); // Hebrew
        put("hi", OrdinalityFamily.FAMILY_4); // Hindi
        put("hr", OrdinalityFamily.FAMILY_1); // Croatian
        put("hsb", OrdinalityFamily.FAMILY_1); // Upper Sorbian
        put("hu", OrdinalityFamily.FAMILY_10); // Hungarian
        put("hy", OrdinalityFamily.FAMILY_2); // Armenian
        put("id", OrdinalityFamily.FAMILY_1); // Indonesian
        put("ig", OrdinalityFamily.FAMILY_1); // Igbo (no CLDR data available)
        put("ii", OrdinalityFamily.FAMILY_1); // Sichuan Yi (no CLDR data available)
        put("is", OrdinalityFamily.FAMILY_1); // Icelandic
        put("it", OrdinalityFamily.FAMILY_11); // Italian
        put("iu", OrdinalityFamily.FAMILY_1); // Inuktitut (no CLDR data available)
        put("ja", OrdinalityFamily.FAMILY_1); // Japanese
        put("jbo", OrdinalityFamily.FAMILY_1); // Lojban (no CLDR data available)
        put("jgo", OrdinalityFamily.FAMILY_1); // Ngomba (no CLDR data available)
        put("jmc", OrdinalityFamily.FAMILY_1); // Machame (no CLDR data available)
        put("jv", OrdinalityFamily.FAMILY_1); // Javanese (no CLDR data available)
        put("jw", OrdinalityFamily.FAMILY_1); // Javanese (no CLDR data available)
        put("ka", OrdinalityFamily.FAMILY_12); // Georgian
        put("kab", OrdinalityFamily.FAMILY_1); // Kabyle (no CLDR data available)
        put("kaj", OrdinalityFamily.FAMILY_1); // Jju (no CLDR data available)
        put("kcg", OrdinalityFamily.FAMILY_1); // Tyap (no CLDR data available)
        put("kde", OrdinalityFamily.FAMILY_1); // Makonde (no CLDR data available)
        put("kea", OrdinalityFamily.FAMILY_1); // Kabuverdianu (no CLDR data available)
        put("kk", OrdinalityFamily.FAMILY_13); // Kazakh
        put("kkj", OrdinalityFamily.FAMILY_1); // Kako (no CLDR data available)
        put("kl", OrdinalityFamily.FAMILY_1); // Greenlandic (no CLDR data available)
        put("km", OrdinalityFamily.FAMILY_1); // Khmer
        put("kn", OrdinalityFamily.FAMILY_1); // Kannada
        put("ko", OrdinalityFamily.FAMILY_1); // Korean
        put("ks", OrdinalityFamily.FAMILY_1); // Kashmiri (no CLDR data available)
        put("ksb", OrdinalityFamily.FAMILY_1); // Shambala (no CLDR data available)
        put("ksh", OrdinalityFamily.FAMILY_1); // Colognian (no CLDR data available)
        put("ku", OrdinalityFamily.FAMILY_1); // Kurdish (no CLDR data available)
        put("kw", OrdinalityFamily.FAMILY_1); // Cornish (no CLDR data available)
        put("ky", OrdinalityFamily.FAMILY_1); // Kirghiz
        put("lag", OrdinalityFamily.FAMILY_1); // Langi (no CLDR data available)
        put("lb", OrdinalityFamily.FAMILY_1); // Luxembourgish (no CLDR data available)
        put("lg", OrdinalityFamily.FAMILY_1); // Ganda (no CLDR data available)
        put("lkt", OrdinalityFamily.FAMILY_1); // Lakota (no CLDR data available)
        put("ln", OrdinalityFamily.FAMILY_1); // Lingala (no CLDR data available)
        put("lo", OrdinalityFamily.FAMILY_2); // Lao
        put("lt", OrdinalityFamily.FAMILY_1); // Lithuanian
        put("lv", OrdinalityFamily.FAMILY_1); // Latvian
        put("mas", OrdinalityFamily.FAMILY_1); // Masai (no CLDR data available)
        put("mg", OrdinalityFamily.FAMILY_1); // Malagasy (no CLDR data available)
        put("mgo", OrdinalityFamily.FAMILY_1); // Metaʼ (no CLDR data available)
        put("mk", OrdinalityFamily.FAMILY_14); // Macedonian
        put("ml", OrdinalityFamily.FAMILY_1); // Malayalam
        put("mn", OrdinalityFamily.FAMILY_1); // Mongolian
        put("mo", OrdinalityFamily.FAMILY_2); // Moldovan
        put("mr", OrdinalityFamily.FAMILY_15); // Marathi
        put("ms", OrdinalityFamily.FAMILY_2); // Malay
        put("mt", OrdinalityFamily.FAMILY_1); // Maltese (no CLDR data available)
        put("my", OrdinalityFamily.FAMILY_1); // Burmese
        put("nah", OrdinalityFamily.FAMILY_1); // Nahuatl (no CLDR data available)
        put("naq", OrdinalityFamily.FAMILY_1); // Nama (no CLDR data available)
        put("nb", OrdinalityFamily.FAMILY_1); // Norwegian Bokmål
        put("nd", OrdinalityFamily.FAMILY_1); // North Ndebele (no CLDR data available)
        put("ne", OrdinalityFamily.FAMILY_16); // Nepali
        put("nl", OrdinalityFamily.FAMILY_1); // Dutch
        put("nn", OrdinalityFamily.FAMILY_1); // Norwegian Nynorsk (no CLDR data available)
        put("nnh", OrdinalityFamily.FAMILY_1); // Ngiemboon (no CLDR data available)
        put("no", OrdinalityFamily.FAMILY_1); // Norwegian (no CLDR data available)
        put("nqo", OrdinalityFamily.FAMILY_1); // N’Ko (no CLDR data available)
        put("nr", OrdinalityFamily.FAMILY_1); // South Ndebele (no CLDR data available)
        put("nso", OrdinalityFamily.FAMILY_1); // Northern Sotho (no CLDR data available)
        put("ny", OrdinalityFamily.FAMILY_1); // Nyanja (no CLDR data available)
        put("nyn", OrdinalityFamily.FAMILY_1); // Nyankole (no CLDR data available)
        put("om", OrdinalityFamily.FAMILY_1); // Oromo (no CLDR data available)
        put("or", OrdinalityFamily.FAMILY_1); // Odia (no CLDR data available)
        put("os", OrdinalityFamily.FAMILY_1); // Ossetian (no CLDR data available)
        put("pa", OrdinalityFamily.FAMILY_1); // Punjabi
        put("pap", OrdinalityFamily.FAMILY_1); // Papiamento (no CLDR data available)
        put("pl", OrdinalityFamily.FAMILY_1); // Polish
        put("prg", OrdinalityFamily.FAMILY_1); // Prussian
        put("ps", OrdinalityFamily.FAMILY_1); // Pushto (no CLDR data available)
        put("pt", OrdinalityFamily.FAMILY_1); // Portuguese
        put("rm", OrdinalityFamily.FAMILY_1); // Romansh (no CLDR data available)
        put("ro", OrdinalityFamily.FAMILY_2); // Romanian
        put("rof", OrdinalityFamily.FAMILY_1); // Rombo (no CLDR data available)
        put("root", OrdinalityFamily.FAMILY_1); // Root
        put("ru", OrdinalityFamily.FAMILY_1); // Russian
        put("rwk", OrdinalityFamily.FAMILY_1); // Rwa (no CLDR data available)
        put("sah", OrdinalityFamily.FAMILY_1); // Sakha (no CLDR data available)
        put("saq", OrdinalityFamily.FAMILY_1); // Samburu (no CLDR data available)
        put("sdh", OrdinalityFamily.FAMILY_1); // Southern Kurdish (no CLDR data available)
        put("se", OrdinalityFamily.FAMILY_1); // Northern Sami (no CLDR data available)
        put("seh", OrdinalityFamily.FAMILY_1); // Sena (no CLDR data available)
        put("ses", OrdinalityFamily.FAMILY_1); // Koyraboro Senni (no CLDR data available)
        put("sg", OrdinalityFamily.FAMILY_1); // Sango (no CLDR data available)
        put("sh", OrdinalityFamily.FAMILY_1); // Serbo-Croatian
        put("shi", OrdinalityFamily.FAMILY_1); // Tachelhit (no CLDR data available)
        put("si", OrdinalityFamily.FAMILY_1); // Sinhalese
        put("sk", OrdinalityFamily.FAMILY_1); // Slovak
        put("sl", OrdinalityFamily.FAMILY_1); // Slovenian
        put("sma", OrdinalityFamily.FAMILY_1); // Southern Sami (no CLDR data available)
        put("smi", OrdinalityFamily.FAMILY_1); // Sami (no CLDR data available)
        put("smj", OrdinalityFamily.FAMILY_1); // <NAME> (no CLDR data available)
        put("smn", OrdinalityFamily.FAMILY_1); // Inari Sami (no CLDR data available)
        put("sms", OrdinalityFamily.FAMILY_1); // <NAME> (no CLDR data available)
        put("sn", OrdinalityFamily.FAMILY_1); // Shona (no CLDR data available)
        put("so", OrdinalityFamily.FAMILY_1); // Somali (no CLDR data available)
        put("sq", OrdinalityFamily.FAMILY_17); // Albanian
        put("sr", OrdinalityFamily.FAMILY_1); // Serbian
        put("ss", OrdinalityFamily.FAMILY_1); // Swati (no CLDR data available)
        put("ssy", OrdinalityFamily.FAMILY_1); // Saho (no CLDR data available)
        put("st", OrdinalityFamily.FAMILY_1); // Southern Sotho (no CLDR data available)
        put("sv", OrdinalityFamily.FAMILY_18); // Swedish
        put("sw", OrdinalityFamily.FAMILY_1); // Swahili
        put("syr", OrdinalityFamily.FAMILY_1); // Syriac (no CLDR data available)
        put("ta", OrdinalityFamily.FAMILY_1); // Tamil
        put("te", OrdinalityFamily.FAMILY_1); // Telugu
        put("teo", OrdinalityFamily.FAMILY_1); // Teso (no CLDR data available)
        put("th", OrdinalityFamily.FAMILY_1); // Thai
        put("ti", OrdinalityFamily.FAMILY_1); // Tigrinya (no CLDR data available)
        put("tig", OrdinalityFamily.FAMILY_1); // Tigre (no CLDR data available)
        put("tk", OrdinalityFamily.FAMILY_1); // Turkmen (no CLDR data available)
        put("tl", OrdinalityFamily.FAMILY_2); // Tagalog
        put("tn", OrdinalityFamily.FAMILY_1); // Tswana (no CLDR data available)
        put("to", OrdinalityFamily.FAMILY_1); // Tongan (no CLDR data available)
        put("tr", OrdinalityFamily.FAMILY_1); // Turkish
        put("ts", OrdinalityFamily.FAMILY_1); // Tsonga (no CLDR data available)
        put("tzm", OrdinalityFamily.FAMILY_1); // Central Atlas Tamazight (no CLDR data available)
        put("ug", OrdinalityFamily.FAMILY_1); // Uighur (no CLDR data available)
        put("uk", OrdinalityFamily.FAMILY_19); // Ukrainian
        put("ur", OrdinalityFamily.FAMILY_1); // Urdu
        put("uz", OrdinalityFamily.FAMILY_1); // Uzbek
        put("ve", OrdinalityFamily.FAMILY_1); // Venda (no CLDR data available)
        put("vi", OrdinalityFamily.FAMILY_2); // Vietnamese
        put("vo", OrdinalityFamily.FAMILY_1); // Volapük (no CLDR data available)
        put("vun", OrdinalityFamily.FAMILY_1); // Vunjo (no CLDR data available)
        put("wa", OrdinalityFamily.FAMILY_1); // Walloon (no CLDR data available)
        put("wae", OrdinalityFamily.FAMILY_1); // Walser (no CLDR data available)
        put("wo", OrdinalityFamily.FAMILY_1); // Wolof (no CLDR data available)
        put("xh", OrdinalityFamily.FAMILY_1); // Xhosa (no CLDR data available)
        put("xog", OrdinalityFamily.FAMILY_1); // Soga (no CLDR data available)
        put("yi", OrdinalityFamily.FAMILY_1); // Yiddish (no CLDR data available)
        put("yo", OrdinalityFamily.FAMILY_1); // Yoruba (no CLDR data available)
        put("yue", OrdinalityFamily.FAMILY_1); // Cantonese
        put("zh", OrdinalityFamily.FAMILY_1); // Mandarin Chinese
        put("zu", OrdinalityFamily.FAMILY_1); // Zulu
      }});

      // Language codes are in English - force collation for sorting
      SortedSet<String> supportedLanguageCodes = new TreeSet<>(Collator.getInstance(Locale.ENGLISH));
      supportedLanguageCodes.addAll(ORDINALITY_FAMILIES_BY_LANGUAGE_CODE.keySet());

      SUPPORTED_LANGUAGE_CODES = Collections.unmodifiableSortedSet(supportedLanguageCodes);
    }

    /**
     * Gets the ordinality-determining function for this ordinality family.
     * <p>
     * The function takes a numeric value as input and returns the appropriate ordinal form.
     * <p>
     * The function's input must not be null and its output is guaranteed non-null.
     *
     * @return the ordinality-determining function for this ordinality family, not null
     */
    @Nonnull
    public Function<BigDecimal, Ordinality> getOrdinalityFunction() {
      return ordinalityFunction;
    }

    /**
     * Gets the ordinalities supported by this ordinality family.
     * <p>
     * There will always be at least one value - {@link Ordinality#OTHER} - in the set.
     * <p>
     * The set's values are sorted by the natural ordering of the {@link Ordinality} enumeration.
     *
     * @return the ordinalities supported by this ordinality family, not null
     */
    @Nonnull
    SortedSet<Ordinality> getSupportedOrdinalities() {
      return supportedOrdinalities;
    }

    /**
     * Gets a mapping of ordinalities to example integer values for this ordinality family.
     * <p>
     * The map may be empty.
     * <p>
     * The map's keys are sorted by the natural ordering of the {@link Ordinality} enumeration.
     *
     * @return a mapping of ordinalities to example integer values, not null
     */
    @Nonnull
    SortedMap<Ordinality, Range<Integer>> getExampleIntegerValuesByOrdinality() {
      return exampleIntegerValuesByOrdinality;
    }

    /**
     * Gets the ISO 639 language codes for which ordinality operations are supported.
     * <p>
     * The set's values are ISO 639 codes and therefore sorted using English collation.
     *
     * @return the ISO 639 language codes for which ordinality operations are supported, not null
     */
    @Nonnull
    static SortedSet<String> getSupportedLanguageCodes() {
      return SUPPORTED_LANGUAGE_CODES;
    }

    /**
     * Gets an appropriate plural ordinality family for the given locale.
     *
     * @param locale the locale to check, not null
     * @return the appropriate plural ordinality family (if one exists) for the given locale, not null
     */
    @Nonnull
    static Optional<OrdinalityFamily> ordinalityFamilyForLocale(@Nonnull Locale locale) {
      requireNonNull(locale);

      String language = LocaleUtils.normalizedLanguage(locale).orElse(null);
      String country = locale.getCountry();

      OrdinalityFamily ordinalityFamily = null;

      if (language != null && country != null)
        ordinalityFamily = ORDINALITY_FAMILIES_BY_LANGUAGE_CODE.get(format("%s-%s", language, country));

      if (ordinalityFamily != null)
        return Optional.of(ordinalityFamily);

      if (language != null)
        ordinalityFamily = ORDINALITY_FAMILIES_BY_LANGUAGE_CODE.get(language);

      return Optional.ofNullable(ordinalityFamily);
    }
  }
}

<|start_filename|>src/main/java/com/lokalized/UnsupportedLocaleException.java<|end_filename|>
/*
 * Copyright 2017 Product Mog LLC.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.lokalized;

import javax.annotation.Nonnull;
import javax.annotation.concurrent.NotThreadSafe;
import java.util.Locale;

import static java.lang.String.format;
import static java.util.Objects.requireNonNull;

/**
 * Exception thrown when an operation was performed on a locale that is not recognized by the system.
 * <p>
 * This class is intended for use by a single thread.
 *
 * @author <a href="https://revetkn.com"><NAME></a>
 */
@NotThreadSafe
public class UnsupportedLocaleException extends RuntimeException {
  @Nonnull
  private final Locale locale;

  /**
   * Constructs a new exception with the unsupported locale.
   *
   * @param locale the unsupported locale which triggered this exception, not null
   */
  public UnsupportedLocaleException(@Nonnull Locale locale) {
    super(format("Unsupported locale '%s' was provided", requireNonNull(locale).toLanguageTag()));
    this.locale = locale;
  }

  /**
   * The unsupported locale that triggered this exception.
   *
   * @return the unsupported locale, not null
   */
  @Nonnull
  public Locale getLocale() {
    return locale;
  }
}

<|start_filename|>src/test/java/com/lokalized/LocalizedStringLoaderTests.java<|end_filename|>
/*
 * Copyright 2017 Product Mog LLC.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.lokalized;

import org.junit.Assert;
import org.junit.Test;

import javax.annotation.Nonnull;
import javax.annotation.concurrent.ThreadSafe;
import java.nio.file.Paths;
import java.util.Locale;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;

import static java.lang.String.format;
import static java.util.Objects.requireNonNull;

/**
 * Exercises {@link LocalizedStringLoader}.
 *
 * @author <a href="https://revetkn.com"><NAME></a>
 */
@ThreadSafe
public class LocalizedStringLoaderTests {
  @Test
  public void testClasspathLoading() {
    verifyLocalizedStringsByLocale(LocalizedStringLoader.loadFromClasspath("strings"));
  }

  @Test
  public void testFilesystemLoading() {
    verifyLocalizedStringsByLocale(LocalizedStringLoader.loadFromFilesystem(Paths.get("src/test/resources/strings")));
  }

  protected void verifyLocalizedStringsByLocale(@Nonnull Map<Locale, Set<LocalizedString>> localizedStringsByLocale) {
    requireNonNull(localizedStringsByLocale);

    Assert.assertEquals("Unexpected number of strings files", 4, localizedStringsByLocale.size());

    for (Entry<Locale, Set<LocalizedString>> entry : localizedStringsByLocale.entrySet())
      Assert.assertTrue(format("The '%s' strings file has no data", entry.getKey().toLanguageTag()),
          entry.getValue().size() > 0);
  }
}


<|start_filename|>src/test/java/com/lokalized/ExpressionTokenizerTests.java<|end_filename|>
/*
 * Copyright 2017 Product Mog LLC.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.lokalized;

import org.junit.Test;

import javax.annotation.concurrent.ThreadSafe;
import java.util.ArrayList;
import java.util.List;

import static org.junit.Assert.assertEquals;

/**
 * Exercises {@link ExpressionTokenizer}.
 *
 * @author <a href="https://revetkn.com"><NAME></a>
 */
@ThreadSafe
public class ExpressionTokenizerTests {
  @Test
  public void basicTokenization() {
    List<Token> tokens = new ExpressionTokenizer().extractTokens("chickenCount == CARDINALITY_ZERO");

    List<Token> expectedTokens = new ArrayList<>();
    expectedTokens.add(new Token(TokenType.VARIABLE, "chickenCount"));
    expectedTokens.add(new Token(TokenType.EQUAL_TO));
    expectedTokens.add(new Token(TokenType.CARDINALITY_ZERO));

    assertEquals(expectedTokens, tokens);
  }

  @Test
  public void numberTokenization() {
    List<Token> tokens = new ExpressionTokenizer().extractTokens("chickenCount == -12.5");

    List<Token> expectedTokens = new ArrayList<>();
    expectedTokens.add(new Token(TokenType.VARIABLE, "chickenCount"));
    expectedTokens.add(new Token(TokenType.EQUAL_TO));
    expectedTokens.add(new Token(TokenType.NUMBER, "-12.5"));

    assertEquals(expectedTokens, tokens);
  }

  @Test
  public void shortMalformedTokenization() {
    List<Token> tokens = new ExpressionTokenizer().extractTokens("chickenCount");

    List<Token> expectedTokens = new ArrayList<>();
    expectedTokens.add(new Token(TokenType.VARIABLE, "chickenCount"));

    assertEquals(expectedTokens, tokens);

    tokens = new ExpressionTokenizer().extractTokens("==");

    expectedTokens = new ArrayList<>();
    expectedTokens.add(new Token(TokenType.EQUAL_TO));

    assertEquals(expectedTokens, tokens);

    tokens = new ExpressionTokenizer().extractTokens("<=");

    expectedTokens = new ArrayList<>();
    expectedTokens.add(new Token(TokenType.LESS_THAN_OR_EQUAL_TO));

    assertEquals(expectedTokens, tokens);

    tokens = new ExpressionTokenizer().extractTokens("<");

    expectedTokens = new ArrayList<>();
    expectedTokens.add(new Token(TokenType.LESS_THAN));

    assertEquals(expectedTokens, tokens);

    tokens = new ExpressionTokenizer().extractTokens("");

    expectedTokens = new ArrayList<>();

    assertEquals(expectedTokens, tokens);
  }

  @Test
  public void complexTokenization() {
    List<Token> tokens =
        new ExpressionTokenizer()
            .extractTokens("(chickenCount == CARDINALITY_ZERO && rabbitCount == CARDINALITY_ZERO) || (rabbitCount >= CARDINALITY_ONE)");

    List<Token> expectedTokens = new ArrayList<>();
    expectedTokens.add(new Token(TokenType.GROUP_START));
    expectedTokens.add(new Token(TokenType.VARIABLE, "chickenCount"));
    expectedTokens.add(new Token(TokenType.EQUAL_TO));
    expectedTokens.add(new Token(TokenType.CARDINALITY_ZERO));
    expectedTokens.add(new Token(TokenType.AND));
    expectedTokens.add(new Token(TokenType.VARIABLE, "rabbitCount"));
    expectedTokens.add(new Token(TokenType.EQUAL_TO));
    expectedTokens.add(new Token(TokenType.CARDINALITY_ZERO));
    expectedTokens.add(new Token(TokenType.GROUP_END));
    expectedTokens.add(new Token(TokenType.OR));
    expectedTokens.add(new Token(TokenType.GROUP_START));
    expectedTokens.add(new Token(TokenType.VARIABLE, "rabbitCount"));
    expectedTokens.add(new Token(TokenType.GREATER_THAN_OR_EQUAL_TO));
    expectedTokens.add(new Token(TokenType.CARDINALITY_ONE));
    expectedTokens.add(new Token(TokenType.GROUP_END));

    assertEquals(expectedTokens, tokens);
  }

  @Test
  public void malformedTokenization() {
    List<Token> tokens = new ExpressionTokenizer().extractTokens("(((<=)");

    List<Token> expectedTokens = new ArrayList<>();
    expectedTokens.add(new Token(TokenType.GROUP_START));
    expectedTokens.add(new Token(TokenType.GROUP_START));
    expectedTokens.add(new Token(TokenType.GROUP_START));
    expectedTokens.add(new Token(TokenType.LESS_THAN_OR_EQUAL_TO));
    expectedTokens.add(new Token(TokenType.GROUP_END));

    assertEquals(expectedTokens, tokens);

    tokens = new ExpressionTokenizer().extractTokens("(<===>=()))(<=(asdf<");

    expectedTokens = new ArrayList<>();
    expectedTokens.add(new Token(TokenType.GROUP_START));
    expectedTokens.add(new Token(TokenType.LESS_THAN_OR_EQUAL_TO));
    expectedTokens.add(new Token(TokenType.EQUAL_TO));
    expectedTokens.add(new Token(TokenType.GREATER_THAN_OR_EQUAL_TO));
    expectedTokens.add(new Token(TokenType.GROUP_START));
    expectedTokens.add(new Token(TokenType.GROUP_END));
    expectedTokens.add(new Token(TokenType.GROUP_END));
    expectedTokens.add(new Token(TokenType.GROUP_END));
    expectedTokens.add(new Token(TokenType.GROUP_START));
    expectedTokens.add(new Token(TokenType.LESS_THAN_OR_EQUAL_TO));
    expectedTokens.add(new Token(TokenType.GROUP_START));
    expectedTokens.add(new Token(TokenType.VARIABLE, "asdf"));
    expectedTokens.add(new Token(TokenType.LESS_THAN));

    assertEquals(expectedTokens, tokens);

    tokens = new ExpressionTokenizer()
        .extractTokens("(chickenCount == CARDINALITY_ZERO && rabbitCount == CARDINALITY_ZERO) || (r||<=abbitCount >= CARDINALITY_ONE)");

    expectedTokens = new ArrayList<>();
    expectedTokens.add(new Token(TokenType.GROUP_START));
    expectedTokens.add(new Token(TokenType.VARIABLE, "chickenCount"));
    expectedTokens.add(new Token(TokenType.EQUAL_TO));
    expectedTokens.add(new Token(TokenType.CARDINALITY_ZERO));
    expectedTokens.add(new Token(TokenType.AND));
    expectedTokens.add(new Token(TokenType.VARIABLE, "rabbitCount"));
    expectedTokens.add(new Token(TokenType.EQUAL_TO));
    expectedTokens.add(new Token(TokenType.CARDINALITY_ZERO));
    expectedTokens.add(new Token(TokenType.GROUP_END));
    expectedTokens.add(new Token(TokenType.OR));
    expectedTokens.add(new Token(TokenType.GROUP_START));
    expectedTokens.add(new Token(TokenType.VARIABLE, "r"));
    expectedTokens.add(new Token(TokenType.OR));
    expectedTokens.add(new Token(TokenType.LESS_THAN_OR_EQUAL_TO));
    expectedTokens.add(new Token(TokenType.VARIABLE, "abbitCount"));
    expectedTokens.add(new Token(TokenType.GREATER_THAN_OR_EQUAL_TO));
    expectedTokens.add(new Token(TokenType.CARDINALITY_ONE));
    expectedTokens.add(new Token(TokenType.GROUP_END));

    assertEquals(expectedTokens, tokens);
  }

  @Test
  public void embeddedReservedWordTokenization() {
    List<Token> tokens = new ExpressionTokenizer().extractTokens("justCARDINALITY_ONEtesting == example");

    List<Token> expectedTokens = new ArrayList<>();
    expectedTokens.add(new Token(TokenType.VARIABLE, "justCARDINALITY_ONEtesting"));
    expectedTokens.add(new Token(TokenType.EQUAL_TO));
    expectedTokens.add(new Token(TokenType.VARIABLE, "example"));

    assertEquals(expectedTokens, tokens);
  }

  @Test
  public void shortInvalidTokenization() {
    List<Token> tokens = new ExpressionTokenizer().extractTokens("== example");

    List<Token> expectedTokens = new ArrayList<>();
    expectedTokens.add(new Token(TokenType.EQUAL_TO));
    expectedTokens.add(new Token(TokenType.VARIABLE, "example"));

    assertEquals(expectedTokens, tokens);

    tokens = new ExpressionTokenizer().extractTokens("example");

    expectedTokens = new ArrayList<>();
    expectedTokens.add(new Token(TokenType.VARIABLE, "example"));

    assertEquals(expectedTokens, tokens);

    tokens = new ExpressionTokenizer().extractTokens("     ");

    expectedTokens = new ArrayList<>();

    assertEquals(expectedTokens, tokens);
  }
}

<|start_filename|>src/main/java/com/lokalized/CardinalityRange.java<|end_filename|>
/*
 * Copyright 2017 Product Mog LLC.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.lokalized;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.annotation.concurrent.Immutable;
import java.util.Objects;

import static java.util.Objects.requireNonNull;

/**
 * Represents a cardinality range - for example, {@code "1-3 meters"}.
 * <p>
 * The start of the above range would be the cardinality that maps to {@code 1} and the end
 * would be the cardinality that maps to {@code 3}.
 *
 * @author <a href="https://revetkn.com"><NAME></a>
 */
@Immutable
class CardinalityRange implements Comparable<CardinalityRange> {
  @Nonnull
  private final Cardinality start;
  @Nonnull
  private final Cardinality end;

  /**
   * Provides a cardinality range with the given start and end cardinalities.
   *
   * @param start the range's start cardinality, not null
   * @param end   the range's end cardinality, not null
   * @return a cardinality range with the given start and end cardinalities, not null
   */
  @Nonnull
  static CardinalityRange of(@Nonnull Cardinality start, @Nonnull Cardinality end) {
    requireNonNull(start);
    requireNonNull(end);

    // Future: share/recycle instances?
    return new CardinalityRange(start, end);
  }

  /**
   * Constructs a cardinality range with the given start and end cardinalities.
   * <p>
   * Currently private and only invoked via {@link #of(Cardinality, Cardinality)} for futureproofing (perhaps we share/recycle instances).
   *
   * @param start the range's start cardinality, not null
   * @param end   the range's end cardinality, not null
   */
  private CardinalityRange(@Nonnull Cardinality start, @Nonnull Cardinality end) {
    requireNonNull(start);
    requireNonNull(end);

    this.start = start;
    this.end = end;
  }

  /**
   * Compares this cardinality range to another.
   *
   * @param cardinalityRange the cardinality range against which to compare, not null
   * @return the comparison result
   */
  @Override
  public int compareTo(@Nonnull CardinalityRange cardinalityRange) {
    requireNonNull(cardinalityRange);

    int comparison = getStart().compareTo(cardinalityRange.getStart());

    if (comparison == 0)
      comparison = getEnd().compareTo(cardinalityRange.getEnd());

    return comparison;
  }

  /**
   * Generates a {@code String} representation of this object.
   *
   * @return a string representation of this object, not null
   */
  @Override
  @Nonnull
  public String toString() {
    return String.format("%s{start=%s, end=%s}", getClass().getSimpleName(), getStart().name(), getEnd().name());
  }

  /**
   * Checks if this object is equal to another one.
   *
   * @param other the object to check, null returns false
   * @return true if this is equal to the other object, false otherwise
   */
  @Override
  public boolean equals(@Nullable Object other) {
    if (this == other)
      return true;

    if (other == null || !getClass().equals(other.getClass()))
      return false;

    CardinalityRange cardinalityRange = (CardinalityRange) other;

    return Objects.equals(getStart(), cardinalityRange.getStart())
        && Objects.equals(getEnd(), cardinalityRange.getEnd());
  }

  /**
   * A hash code for this object.
   *
   * @return a suitable hash code
   */
  @Override
  public int hashCode() {
    return Objects.hash(getStart(), getEnd());
  }

  /**
   * Gets the range's start cardinality.
   *
   * @return the range's start cardinality, not null
   */
  @Nonnull
  public Cardinality getStart() {
    return start;
  }

  /**
   * Gets the range's end cardinality.
   *
   * @return the range's end cardinality, not null
   */
  @Nonnull
  public Cardinality getEnd() {
    return end;
  }
}

<|start_filename|>src/test/java/com/lokalized/NumberUtilsTests.java<|end_filename|>
/*
 * Copyright 2017 Product Mog LLC.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.lokalized;

import org.junit.Test;

import javax.annotation.concurrent.ThreadSafe;
import java.math.BigDecimal;
import java.math.BigInteger;

import static org.junit.Assert.assertEquals;

/**
 * Exercises {@link NumberUtils}.
 *
 * @author <a href="https://revetkn.com"><NAME></a>
 */
@ThreadSafe
public class NumberUtilsTests {
  @Test
  public void numberOfDecimalPlaces() {
    assertEquals(1, NumberUtils.numberOfDecimalPlaces(new BigDecimal("1.0")).intValue());
    assertEquals(0, NumberUtils.numberOfDecimalPlaces(new BigDecimal("1")).intValue());
    assertEquals(2, NumberUtils.numberOfDecimalPlaces(new BigDecimal("1.50")).intValue());
    assertEquals(0, NumberUtils.numberOfDecimalPlaces(1.0).intValue());
    assertEquals(1, NumberUtils.numberOfDecimalPlaces(1.50).intValue());
    assertEquals(0, NumberUtils.numberOfDecimalPlaces(150).intValue());
  }

  @Test
  public void integerComponent() {
    assertEquals(BigInteger.valueOf(0L), NumberUtils.integerComponent(new BigDecimal("0.0")));
    assertEquals(BigInteger.valueOf(0L), NumberUtils.integerComponent(new BigDecimal("0.1")));
    assertEquals(BigInteger.valueOf(1L), NumberUtils.integerComponent(new BigDecimal("1.0")));
    assertEquals(BigInteger.valueOf(-1L), NumberUtils.integerComponent(new BigDecimal("-1")));
    assertEquals(BigInteger.valueOf(-1L), NumberUtils.integerComponent(new BigDecimal("-1.5")));
    assertEquals(BigInteger.valueOf(45L), NumberUtils.integerComponent(new BigDecimal("45")));
    assertEquals(BigInteger.valueOf(45L), NumberUtils.integerComponent(new BigDecimal("45.6")));
    assertEquals(BigInteger.valueOf(0L), NumberUtils.integerComponent(0));
    assertEquals(BigInteger.valueOf(0L), NumberUtils.integerComponent(0.1));
    assertEquals(BigInteger.valueOf(-1L), NumberUtils.integerComponent(-1));
    assertEquals(BigInteger.valueOf(-1L), NumberUtils.integerComponent(-1.5));
    assertEquals(BigInteger.valueOf(45L), NumberUtils.integerComponent(45));
    assertEquals(BigInteger.valueOf(45L), NumberUtils.integerComponent(45.6));
  }

  @Test
  public void fractionalComponent() {
    assertEquals(BigInteger.valueOf(0L), NumberUtils.fractionalComponent(new BigDecimal("0.0")));
    assertEquals(BigInteger.valueOf(1L), NumberUtils.fractionalComponent(new BigDecimal("0.1")));
    assertEquals(BigInteger.valueOf(0L), NumberUtils.fractionalComponent(new BigDecimal("1.0")));
    assertEquals(BigInteger.valueOf(0L), NumberUtils.fractionalComponent(new BigDecimal("-1")));
    assertEquals(BigInteger.valueOf(5L), NumberUtils.fractionalComponent(new BigDecimal("-1.5")));
    assertEquals(BigInteger.valueOf(0L), NumberUtils.fractionalComponent(new BigDecimal("45")));
    assertEquals(BigInteger.valueOf(600L), NumberUtils.fractionalComponent(new BigDecimal("45.600")));
    assertEquals(BigInteger.valueOf(0L), NumberUtils.fractionalComponent(0));
    assertEquals(BigInteger.valueOf(1L), NumberUtils.fractionalComponent(0.1));
    assertEquals(BigInteger.valueOf(0L), NumberUtils.fractionalComponent(1.0));
    assertEquals(BigInteger.valueOf(0L), NumberUtils.fractionalComponent(-1));
    assertEquals(BigInteger.valueOf(5L), NumberUtils.fractionalComponent(-1.5));
    assertEquals(BigInteger.valueOf(0L), NumberUtils.fractionalComponent(45));
    assertEquals(BigInteger.valueOf(6L), NumberUtils.fractionalComponent(45.600));
  }
}

<|start_filename|>src/main/java/com/lokalized/StringInterpolator.java<|end_filename|>
/*
 * Copyright 2017 Product Mog LLC.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.lokalized;

import javax.annotation.Nonnull;
import javax.annotation.concurrent.ThreadSafe;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import static java.lang.String.format;

/**
 * Merges data into a string using {@code {{placeholder}} syntax}.
 *
 * @author <a href="https://revetkn.com"><NAME></a>
 */
@ThreadSafe
class StringInterpolator {
  @Nonnull
  private static final Pattern PLACEHOLDER_PATTERN;

  static {
    PLACEHOLDER_PATTERN = Pattern.compile("\\{\\{\\p{Alnum}+}}");
  }

  @Nonnull
  public String interpolate(@Nonnull String string, @Nonnull Map<String, Object> context) {
    Matcher matcher = PLACEHOLDER_PATTERN.matcher(string);

    // Matcher#appendReplacement only accepts StringBuffer, not StringBuilder
    StringBuffer stringBuffer = new StringBuffer();

    while (matcher.find()) {
      String name = matcher.group();
      name = name.substring("{{".length(), name.length() - "}}".length());
      Object value = context.get(name);

      if (value instanceof Optional)
        value = ((Optional<?>) value).orElse(null);

      if (value == null)
        matcher.appendReplacement(stringBuffer, format("{{%s}}", name));
      else
        matcher.appendReplacement(stringBuffer, value == null ? "" : value.toString());
    }

    matcher.appendTail(stringBuffer);

    return stringBuffer.toString();
  }
}


<|start_filename|>src/test/java/com/lokalized/StringsTests.java<|end_filename|>
/*
 * Copyright 2017 Product Mog LLC.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.lokalized;

import org.junit.Assert;
import org.junit.BeforeClass;
import org.junit.Test;

import javax.annotation.concurrent.ThreadSafe;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Locale;
import java.util.Locale.LanguageRange;
import java.util.logging.Level;

/**
 * Exercises {@link Strings}.
 *
 * @author <a href="https://revetkn.com"><NAME></a>
 */
@ThreadSafe
public class StringsTests {
  @BeforeClass
  public static void configureLogging() {
    LoggingUtils.setRootLoggerLevel(Level.FINER);
  }

  @Test
  public void basicLanguageSpecificityTest() {
    Strings strings = new DefaultStrings.Builder("en", () ->
        LocalizedStringLoader.loadFromClasspath("strings")
    ).localeSupplier(() -> Locale.forLanguageTag("en-GB"))
        .build();

    String translation = strings.get("I am going on vacation");

    Assert.assertEquals("I am going on holiday", translation);
  }

  @Test
  public void cardinalityPlaceholderTest() {
    Strings strings = new DefaultStrings.Builder("en", () ->
        LocalizedStringLoader.loadFromClasspath("strings")
    ).build();

    String translation = strings.get("I read {{bookCount}} books",
        new HashMap<String, Object>() {{
          put("bookCount", 3);
        }});

    Assert.assertEquals("I read 3 books", translation);

    translation = strings.get("I read {{bookCount}} books",
        new HashMap<String, Object>() {{
          put("bookCount", 1);
        }});

    Assert.assertEquals("I read 1 book", translation);

    translation = strings.get("I read {{bookCount}} books",
        new HashMap<String, Object>() {{
          put("bookCount", new BigDecimal("1.0"));
        }});

    Assert.assertEquals("I read 1.0 books", translation);

    translation = strings.get("I read {{bookCount}} books",
        new HashMap<String, Object>() {{
          put("bookCount", 3);
        }}, Locale.forLanguageTag("ru"));

    Assert.assertEquals("I прочитал 3 книг", translation);
  }

  @Test
  public void ordinalityPlaceholderTest() {
    Strings strings = new DefaultStrings.Builder("en", () ->
        LocalizedStringLoader.loadFromClasspath("strings")
    ).build();

    String translation = strings.get("{{hisOrHer}} {{year}}th birthday party is next week.",
        new HashMap<String, Object>() {{
          put("hisOrHer", Gender.MASCULINE);
          put("year", 18);
        }});

    Assert.assertEquals("His 18th birthday party is next week.", translation);

    translation = strings.get("{{hisOrHer}} {{year}}th birthday party is next week.",
        new HashMap<String, Object>() {{
          put("hisOrHer", Gender.FEMININE);
          put("year", 21);
        }});

    Assert.assertEquals("Her 21st birthday party is next week.", translation);

    translation = strings.get("{{hisOrHer}} {{year}}th birthday party is next week.",
        new HashMap<String, Object>() {{
          put("hisOrHer", Gender.MASCULINE);
          put("year", 18);
        }}, Locale.forLanguageTag("es"));

    Assert.assertEquals("Su fiesta de cumpleaños número 18 es la próxima semana.", translation);

    translation = strings.get("{{hisOrHer}} {{year}}th birthday party is next week.",
        new HashMap<String, Object>() {{
          put("year", 1);
        }}, Locale.forLanguageTag("es"));

    Assert.assertEquals("Su primera fiesta de cumpleaños es la próxima semana.", translation);

    translation = strings.get("{{hisOrHer}} {{year}}th birthday party is next week.",
        new HashMap<String, Object>() {{
          put("hisOrHer", Gender.FEMININE);
          put("year", 15);
        }}, Locale.forLanguageTag("es"));

    Assert.assertEquals("Su quinceañera es la próxima semana.", translation);
  }

  @Test
  public void genderPlaceholderTest() {
    Strings strings = new DefaultStrings.Builder("en", () ->
        LocalizedStringLoader.loadFromClasspath("strings")
    ).build();

    String translation = strings.get("{{heOrShe}} is a good actor.",
        new HashMap<String, Object>() {{
          put("heOrShe", Gender.MASCULINE);
        }});

    Assert.assertEquals("He is a good actor.", translation);

    translation = strings.get("{{heOrShe}} is a good actor.",
        new HashMap<String, Object>() {{
          put("heOrShe", Gender.FEMININE);
        }});

    Assert.assertEquals("She is a good actress.", translation);

    translation = strings.get("{{heOrShe}} is a good actor.",
        new HashMap<String, Object>() {{
          put("heOrShe", Gender.MASCULINE);
        }}, Locale.forLanguageTag("es"));

    Assert.assertEquals("Él es un buen actor.", translation);

    translation = strings.get("{{heOrShe}} is a good actor.",
        new HashMap<String, Object>() {{
          put("heOrShe", Gender.FEMININE);
        }}, Locale.forLanguageTag("es"));

    Assert.assertEquals("Ella es una buena actriz.", translation);

    translation = strings.get("{{heOrShe}} is a great actor.",
        new HashMap<String, Object>() {{
          put("heOrShe", Gender.MASCULINE);
        }}, Locale.forLanguageTag("es"));

    Assert.assertEquals("Él es un gran actor.", translation);

    translation = strings.get("{{heOrShe}} is a great actor.",
        new HashMap<String, Object>() {{
          put("heOrShe", Gender.FEMININE);
        }}, Locale.forLanguageTag("es"));

    Assert.assertEquals("Ella es una gran actriz.", translation);
  }

  @Test
  public void alternativesTest() {
    Strings strings = new DefaultStrings.Builder("en", () ->
        LocalizedStringLoader.loadFromClasspath("strings")
    ).build();

    String translation = strings.get("I read {{bookCount}} books",
        new HashMap<String, Object>() {{
          put("bookCount", 0);
        }});

    Assert.assertEquals("I didn't read any books", translation);
  }

  @Test
  public void complexTest() {
    Strings strings = new DefaultStrings.Builder("en", () ->
        LocalizedStringLoader.loadFromClasspath("strings")
    ).build();

    // English

    // He was one of the 10 best baseball players.
    // She was one of the 10 best baseball players.
    // He was the best baseball player.
    // She was the best baseball player.

    String translation = strings.get("{{heOrShe}} was one of the {{groupSize}} best baseball players.",
        new HashMap<String, Object>() {{
          put("heOrShe", Gender.MASCULINE);
          put("groupSize", 10);
        }});

    Assert.assertEquals("He was one of the 10 best baseball players.", translation);

    translation = strings.get("{{heOrShe}} was one of the {{groupSize}} best baseball players.",
        new HashMap<String, Object>() {{
          put("heOrShe", Gender.MASCULINE);
          put("groupSize", 1);
        }});

    Assert.assertEquals("He was the best baseball player.", translation);

    translation = strings.get("{{heOrShe}} was one of the {{groupSize}} best baseball players.",
        new HashMap<String, Object>() {{
          put("heOrShe", Gender.FEMININE);
          put("groupSize", 10);
        }});

    Assert.assertEquals("She was one of the 10 best baseball players.", translation);

    translation = strings.get("{{heOrShe}} was one of the {{groupSize}} best baseball players.",
        new HashMap<String, Object>() {{
          put("heOrShe", Gender.FEMININE);
          put("groupSize", 1);
        }});

    Assert.assertEquals("She was the best baseball player.", translation);

    // Spanish

    // Fue uno de los 10 mejores jugadores de béisbol.
    // Fue una de las 10 mejores jugadoras de béisbol.
    // Él era el mejor jugador de béisbol.
    // Ella era la mejor jugadora de béisbol.

    translation = strings.get("{{heOrShe}} was one of the {{groupSize}} best baseball players.",
        new HashMap<String, Object>() {{
          put("heOrShe", Gender.MASCULINE);
          put("groupSize", 10);
        }}, Locale.forLanguageTag("es"));

    Assert.assertEquals("Fue uno de los 10 mejores jugadores de béisbol.", translation);

    translation = strings.get("{{heOrShe}} was one of the {{groupSize}} best baseball players.",
        new HashMap<String, Object>() {{
          put("heOrShe", Gender.MASCULINE);
          put("groupSize", 1);
        }}, Locale.forLanguageTag("es"));

    Assert.assertEquals("Él era el mejor jugador de béisbol.", translation);

    translation = strings.get("{{heOrShe}} was one of the {{groupSize}} best baseball players.",
        new HashMap<String, Object>() {{
          put("heOrShe", Gender.FEMININE);
          put("groupSize", 10);
        }}, Locale.forLanguageTag("es"));

    Assert.assertEquals("Fue una de las 10 mejores jugadoras de béisbol.", translation);

    translation = strings.get("{{heOrShe}} was one of the {{groupSize}} best baseball players.",
        new HashMap<String, Object>() {{
          put("heOrShe", Gender.FEMININE);
          put("groupSize", 1);
        }}, Locale.forLanguageTag("es"));

    Assert.assertEquals("Ella era la mejor jugadora de béisbol.", translation);
  }

  @Test
  public void missingPlaceholders() {
    Strings strings = new DefaultStrings.Builder("en", () ->
        LocalizedStringLoader.loadFromClasspath("strings")
    ).build();

    String translation = strings.get("I read {{bookCount}} books");

    Assert.assertEquals("I read {{bookCount}} books", translation);
  }

  @Test
  public void languageRange() {
    Strings strings = new DefaultStrings.Builder("en", () ->
        LocalizedStringLoader.loadFromClasspath("strings")
    ).languageRangesSupplier(() -> LanguageRange.parse("en-US;q=1.0,en-GB;q=0.5,fr-FR;q=0.25")).build();

    String translation = strings.get("I am going on vacation");

    Assert.assertEquals("I am going on vacation", translation);

    Strings enGbStrings = new DefaultStrings.Builder("en", () ->
        LocalizedStringLoader.loadFromClasspath("strings")
    ).languageRangesSupplier(() -> LanguageRange.parse("en-GB;q=1.0,en;q=0.75,en-US;q=0.5,fr-FR;q=0.25")).build();

    String enGbTranslation = enGbStrings.get("I am going on vacation");

    Assert.assertEquals("I am going on holiday", enGbTranslation);

    Strings enUsStrings = new DefaultStrings.Builder("ru", () ->
        LocalizedStringLoader.loadFromClasspath("strings")
    ).languageRangesSupplier(() -> LanguageRange.parse("en-US;q=1.0,en-GB;q=0.5,fr-FR;q=0.25")).build();

    String enUsTranslation = enUsStrings.get("I am going on vacation");

    Assert.assertEquals("I am going on vacation", enUsTranslation);

    Strings ruStrings = new DefaultStrings.Builder("ru", () ->
        LocalizedStringLoader.loadFromClasspath("strings")
    ).languageRangesSupplier(() -> LanguageRange.parse("fr;q=1.0,ru;q=0.25")).build();

    String ruTranslation = ruStrings.get("I am going on vacation - MISSING KEY");

    Assert.assertEquals("I am going on vacation - MISSING KEY", ruTranslation);

    Strings ru2Strings = new DefaultStrings.Builder("en", () ->
        LocalizedStringLoader.loadFromClasspath("strings")
    ).languageRangesSupplier(() -> LanguageRange.parse("fr;q=1.0,ru;q=0.25")).build();

    String ru2Translation = ru2Strings.get("Hello, world!");

    Assert.assertEquals("Приветствую, мир", ru2Translation);
  }

  @Test
  public void cardinalityRanges() {
    Strings strings = new DefaultStrings.Builder("en", () ->
        LocalizedStringLoader.loadFromClasspath("strings")
    ).build();

    String enTranslation = strings.get("The meeting will be {{minHours}}-{{maxHours}} hours long.", new HashMap<String, Object>() {{
      put("minHours", 1.5);
      put("maxHours", 2);
    }});

    Assert.assertEquals("The meeting will be 1.5-2 hours long.", enTranslation);
  }

  @Test
  public void noTranslationKeyPlaceholderTest() {
    Strings strings = new DefaultStrings.Builder("en", () ->
        LocalizedStringLoader.loadFromClasspath("strings")
    ).build();

    String translation = strings.get("There is no key for this");

    Assert.assertEquals("There is no key for this", translation);

    translation = strings.get("There is no key for {{this}}", new HashMap<String, Object>() {{
      put("this", "that");
    }});

    Assert.assertEquals("There is no key for that", translation);
  }
}

<|start_filename|>src/test/java/com/lokalized/ExpressionEvaluatorTests.java<|end_filename|>
/*
 * Copyright 2017 Product Mog LLC.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.lokalized;

import org.junit.Assert;
import org.junit.Test;

import javax.annotation.concurrent.NotThreadSafe;
import java.util.HashMap;
import java.util.Locale;

/**
 * Exercises {@link ExpressionEvaluator}.
 *
 * @author <a href="https://revetkn.com"><NAME></a>
 */
@NotThreadSafe
public class ExpressionEvaluatorTests {
  private static final Locale LOCALE = Locale.forLanguageTag("en-US");

  @Test
  public void identityExpressions() {
    ExpressionEvaluator expressionEvaluator = new ExpressionEvaluator();

    Assert.assertTrue("Number identity failed", expressionEvaluator.evaluate("12.5 == 12.5", LOCALE));
    Assert.assertFalse("Unequal numbers evaluate as equal", expressionEvaluator.evaluate("12.5 == 12.6", LOCALE));
    Assert.assertTrue("Unequal numbers evaluate as equal", expressionEvaluator.evaluate("12.5 != 12.6", LOCALE));

    Assert.assertTrue("Gender identity failed", expressionEvaluator.evaluate("MASCULINE == MASCULINE", LOCALE));
    Assert.assertFalse("Unequal genders evaluate as equal", expressionEvaluator.evaluate("MASCULINE == FEMININE", LOCALE));
    Assert.assertTrue("Unequal genders evaluate as equal", expressionEvaluator.evaluate("MASCULINE != FEMININE", LOCALE));

    Assert.assertTrue("Cardinality identity failed", expressionEvaluator.evaluate("CARDINALITY_ONE == CARDINALITY_ONE", LOCALE));
    Assert.assertFalse("Unequal plurals evaluate as equal", expressionEvaluator.evaluate("CARDINALITY_ONE == CARDINALITY_MANY", LOCALE));
    Assert.assertTrue("Unequal plurals evaluate as equal", expressionEvaluator.evaluate("CARDINALITY_ONE != CARDINALITY_MANY", LOCALE));
  }

  @Test
  public void numericOperators() {
    ExpressionEvaluator expressionEvaluator = new ExpressionEvaluator();

    Assert.assertTrue("Number <= failed", expressionEvaluator.evaluate("12.5 <= 12.5", LOCALE));
    Assert.assertTrue("Number < failed", expressionEvaluator.evaluate("12.4 < 12.5", LOCALE));
    Assert.assertTrue("Number >= failed", expressionEvaluator.evaluate("12.5 >= 12.5", LOCALE));
    Assert.assertTrue("Number > failed", expressionEvaluator.evaluate("12.6 > 12.5", LOCALE));
  }

  @Test
  public void contextualExpressions() {
    ExpressionEvaluator expressionEvaluator = new ExpressionEvaluator();

    Assert.assertTrue("Number-variable comparison failed", expressionEvaluator.evaluate("12.5 == x", new HashMap<String, Object>() {{
      put("x", 12.5);
    }}, LOCALE));

    Assert.assertTrue("Gender-variable comparison failed", expressionEvaluator.evaluate("gender == MASCULINE", new HashMap<String, Object>() {{
      put("gender", Gender.MASCULINE);
    }}, LOCALE));

    Assert.assertTrue("Cardinality-variable comparison failed", expressionEvaluator.evaluate("CARDINALITY_OTHER == bigNumber", new HashMap<String, Object>() {{
      put("bigNumber", 1_000);
    }}, LOCALE));

    Assert.assertTrue("Cardinality-variable comparison failed", expressionEvaluator.evaluate("CARDINALITY_ONE == exactlyOne", new HashMap<String, Object>() {{
      put("exactlyOne", 1);
    }}, LOCALE));
  }
}

<|start_filename|>src/main/java/com/lokalized/ExpressionEvaluationException.java<|end_filename|>
/*
 * Copyright 2017 Product Mog LLC.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.lokalized;

import javax.annotation.Nullable;
import javax.annotation.concurrent.NotThreadSafe;

/**
 * Exception which indicates a problem encountered while evaluating a localized string expression.
 * <p>
 * This class is intended for use by a single thread.
 *
 * @author <a href="https://revetkn.com"><NAME></a>
 */
@NotThreadSafe
public class ExpressionEvaluationException extends RuntimeException {
  /**
   * Constructs a new exception with the specified message.
   *
   * @param message the message to use for this exception, may be null
   */
  public ExpressionEvaluationException(@Nullable String message) {
    super(message);
  }

  /**
   * Constructs a new exception with the specified message and cause.
   *
   * @param message the message to use for this exception, may be null
   * @param cause   the cause of the exception, may be null
   */
  public ExpressionEvaluationException(@Nullable String message, @Nullable Throwable cause) {
    super(message, cause);
  }
}

<|start_filename|>src/main/java/com/lokalized/MinimalJson.java<|end_filename|>
/*
 * Copyright 2017 Product Mog LLC.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.lokalized;

import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.Reader;
import java.io.Serializable;
import java.io.StringReader;
import java.io.StringWriter;
import java.io.Writer;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;

/**
 * Embedded version of the <a href="https://github.com/ralfstx/minimal-json">minimal-json</a> library.
 * <p>
 * This is from commit e7f9fe0 on master from Oct 25, 2016.
 * <p>
 * We embed the library directly to avoid having an external dependency.
 * <p>
 * This is package-private since it is only intended for internal Lokalized usage.
 * <p>
 * The minimal-json library is released under the terms of the MIT License:
 * <p>
 * <blockquote>
 * Copyright (c) 2013, 2015 EclipseSource.
 * <p>
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 * <p>
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 * <p>
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 * </blockquote>
 *
 * @author <a href="https://revetkn.com"><NAME></a>
 */
final class MinimalJson {
  private MinimalJson() {
    // Non-instantiable
  }

  /**
   * This class serves as the entry point to the minimal-json API.
   * <p>
   * To <strong>parse</strong> a given JSON input, use the <code>parse()</code> methods like in this
   * example:
   * </p>
   * <pre>
   * JsonObject object = Json.parse(string).asObject();
   * </pre>
   * <p>
   * To <strong>create</strong> a JSON data structure to be serialized, use the methods
   * <code>value()</code>, <code>array()</code>, and <code>object()</code>. For example, the following
   * snippet will produce the JSON string <em>{"foo": 23, "bar": true}</em>:
   * </p>
   * <pre>
   * String string = Json.object().add("foo", 23).add("bar", true).toString();
   * </pre>
   * <p>
   * To create a JSON array from a given Java array, you can use one of the <code>array()</code>
   * methods with varargs parameters:
   * </p>
   * <pre>
   * String[] names = ...
   * JsonArray array = Json.array(names);
   * </pre>
   */
  public static final class Json {

    private Json() {
      // not meant to be instantiated
    }

    /**
     * Represents the JSON literal <code>null</code>.
     */
    public static final JsonValue NULL = new JsonLiteral("null");

    /**
     * Represents the JSON literal <code>true</code>.
     */
    public static final JsonValue TRUE = new JsonLiteral("true");

    /**
     * Represents the JSON literal <code>false</code>.
     */
    public static final JsonValue FALSE = new JsonLiteral("false");

    /**
     * Returns a JsonValue instance that represents the given <code>int</code> value.
     *
     * @param value the value to get a JSON representation for
     * @return a JSON value that represents the given value
     */
    public static JsonValue value(int value) {
      return new JsonNumber(Integer.toString(value, 10));
    }

    /**
     * Returns a JsonValue instance that represents the given <code>long</code> value.
     *
     * @param value the value to get a JSON representation for
     * @return a JSON value that represents the given value
     */
    public static JsonValue value(long value) {
      return new JsonNumber(Long.toString(value, 10));
    }

    /**
     * Returns a JsonValue instance that represents the given <code>float</code> value.
     *
     * @param value the value to get a JSON representation for
     * @return a JSON value that represents the given value
     */
    public static JsonValue value(float value) {
      if (Float.isInfinite(value) || Float.isNaN(value)) {
        throw new IllegalArgumentException("Infinite and NaN values not permitted in JSON");
      }
      return new JsonNumber(cutOffPointZero(Float.toString(value)));
    }

    /**
     * Returns a JsonValue instance that represents the given <code>double</code> value.
     *
     * @param value the value to get a JSON representation for
     * @return a JSON value that represents the given value
     */
    public static JsonValue value(double value) {
      if (Double.isInfinite(value) || Double.isNaN(value)) {
        throw new IllegalArgumentException("Infinite and NaN values not permitted in JSON");
      }
      return new JsonNumber(cutOffPointZero(Double.toString(value)));
    }

    /**
     * Returns a JsonValue instance that represents the given string.
     *
     * @param string the string to get a JSON representation for
     * @return a JSON value that represents the given string
     */
    public static JsonValue value(String string) {
      return string == null ? NULL : new JsonString(string);
    }

    /**
     * Returns a JsonValue instance that represents the given <code>boolean</code> value.
     *
     * @param value the value to get a JSON representation for
     * @return a JSON value that represents the given value
     */
    public static JsonValue value(boolean value) {
      return value ? TRUE : FALSE;
    }

    /**
     * Creates a new empty JsonArray. This is equivalent to creating a new JsonArray using the
     * constructor.
     *
     * @return a new empty JSON array
     */
    public static JsonArray array() {
      return new JsonArray();
    }

    /**
     * Creates a new JsonArray that contains the JSON representations of the given <code>int</code>
     * values.
     *
     * @param values the values to be included in the new JSON array
     * @return a new JSON array that contains the given values
     */
    public static JsonArray array(int... values) {
      if (values == null) {
        throw new NullPointerException("values is null");
      }
      JsonArray array = new JsonArray();
      for (int value : values) {
        array.add(value);
      }
      return array;
    }

    /**
     * Creates a new JsonArray that contains the JSON representations of the given <code>long</code>
     * values.
     *
     * @param values the values to be included in the new JSON array
     * @return a new JSON array that contains the given values
     */
    public static JsonArray array(long... values) {
      if (values == null) {
        throw new NullPointerException("values is null");
      }
      JsonArray array = new JsonArray();
      for (long value : values) {
        array.add(value);
      }
      return array;
    }

    /**
     * Creates a new JsonArray that contains the JSON representations of the given <code>float</code>
     * values.
     *
     * @param values the values to be included in the new JSON array
     * @return a new JSON array that contains the given values
     */
    public static JsonArray array(float... values) {
      if (values == null) {
        throw new NullPointerException("values is null");
      }
      JsonArray array = new JsonArray();
      for (float value : values) {
        array.add(value);
      }
      return array;
    }

    /**
     * Creates a new JsonArray that contains the JSON representations of the given <code>double</code>
     * values.
     *
     * @param values the values to be included in the new JSON array
     * @return a new JSON array that contains the given values
     */
    public static JsonArray array(double... values) {
      if (values == null) {
        throw new NullPointerException("values is null");
      }
      JsonArray array = new JsonArray();
      for (double value : values) {
        array.add(value);
      }
      return array;
    }

    /**
     * Creates a new JsonArray that contains the JSON representations of the given
     * <code>boolean</code> values.
     *
     * @param values the values to be included in the new JSON array
     * @return a new JSON array that contains the given values
     */
    public static JsonArray array(boolean... values) {
      if (values == null) {
        throw new NullPointerException("values is null");
      }
      JsonArray array = new JsonArray();
      for (boolean value : values) {
        array.add(value);
      }
      return array;
    }

    /**
     * Creates a new JsonArray that contains the JSON representations of the given strings.
     *
     * @param strings the strings to be included in the new JSON array
     * @return a new JSON array that contains the given strings
     */
    public static JsonArray array(String... strings) {
      if (strings == null) {
        throw new NullPointerException("values is null");
      }
      JsonArray array = new JsonArray();
      for (String value : strings) {
        array.add(value);
      }
      return array;
    }

    /**
     * Creates a new empty JsonObject. This is equivalent to creating a new JsonObject using the
     * constructor.
     *
     * @return a new empty JSON object
     */
    public static JsonObject object() {
      return new JsonObject();
    }

    /**
     * Parses the given input string as JSON. The input must contain a valid JSON value, optionally
     * padded with whitespace.
     *
     * @param string the input string, must be valid JSON
     * @return a value that represents the parsed JSON
     * @throws ParseException if the input is not valid JSON
     */
    public static JsonValue parse(String string) {
      if (string == null) {
        throw new NullPointerException("string is null");
      }
      DefaultHandler handler = new DefaultHandler();
      new JsonParser(handler).parse(string);
      return handler.getValue();
    }

    /**
     * Reads the entire input from the given reader and parses it as JSON. The input must contain a
     * valid JSON value, optionally padded with whitespace.
     * <p>
     * Characters are read in chunks into an input buffer. Hence, wrapping a reader in an additional
     * <code>BufferedReader</code> likely won't improve reading performance.
     * </p>
     *
     * @param reader the reader to read the JSON value from
     * @return a value that represents the parsed JSON
     * @throws IOException    if an I/O error occurs in the reader
     * @throws ParseException if the input is not valid JSON
     */
    public static JsonValue parse(Reader reader) throws IOException {
      if (reader == null) {
        throw new NullPointerException("reader is null");
      }
      DefaultHandler handler = new DefaultHandler();
      new JsonParser(handler).parse(reader);
      return handler.getValue();
    }

    private static String cutOffPointZero(String string) {
      if (string.endsWith(".0")) {
        return string.substring(0, string.length() - 2);
      }
      return string;
    }

    static class DefaultHandler extends JsonHandler<JsonArray, JsonObject> {

      protected JsonValue value;

      @Override
      public JsonArray startArray() {
        return new JsonArray();
      }

      @Override
      public JsonObject startObject() {
        return new JsonObject();
      }

      @Override
      public void endNull() {
        value = NULL;
      }

      @Override
      public void endBoolean(boolean bool) {
        value = bool ? TRUE : FALSE;
      }

      @Override
      public void endString(String string) {
        value = new JsonString(string);
      }

      @Override
      public void endNumber(String string) {
        value = new JsonNumber(string);
      }

      @Override
      public void endArray(JsonArray array) {
        value = array;
      }

      @Override
      public void endObject(JsonObject object) {
        value = object;
      }

      @Override
      public void endArrayValue(JsonArray array) {
        array.add(value);
      }

      @Override
      public void endObjectValue(JsonObject object, String name) {
        object.add(name, value);
      }

      JsonValue getValue() {
        return value;
      }
    }
  }

  /**
   * Represents a JSON array, an ordered collection of JSON values.
   * <p>
   * Elements can be added using the <code>add(...)</code> methods which accept instances of
   * {@link JsonValue}, strings, primitive numbers, and boolean values. To replace an element of an
   * array, use the <code>set(int, ...)</code> methods.
   * </p>
   * <p>
   * Elements can be accessed by their index using {@link #get(int)}. This class also supports
   * iterating over the elements in document order using an {@link #iterator()} or an enhanced for
   * loop:
   * </p>
   * <pre>
   * for (JsonValue value : jsonArray) {
   *   ...
   * }
   * </pre>
   * <p>
   * An equivalent {@link List} can be obtained from the method {@link #values()}.
   * </p>
   * <p>
   * Note that this class is <strong>not thread-safe</strong>. If multiple threads access a
   * <code>JsonArray</code> instance concurrently, while at least one of these threads modifies the
   * contents of this array, access to the instance must be synchronized externally. Failure to do so
   * may lead to an inconsistent state.
   * </p>
   * <p>
   * This class is <strong>not supposed to be extended</strong> by clients.
   * </p>
   */
  @SuppressWarnings("serial") // use default serial UID
  public static class JsonArray extends JsonValue implements Iterable<JsonValue> {

    private final List<JsonValue> values;

    /**
     * Creates a new empty JsonArray.
     */
    public JsonArray() {
      values = new ArrayList<JsonValue>();
    }

    /**
     * Creates a new JsonArray with the contents of the specified JSON array.
     *
     * @param array the JsonArray to get the initial contents from, must not be <code>null</code>
     */
    public JsonArray(JsonArray array) {
      this(array, false);
    }

    private JsonArray(JsonArray array, boolean unmodifiable) {
      if (array == null) {
        throw new NullPointerException("array is null");
      }
      if (unmodifiable) {
        values = Collections.unmodifiableList(array.values);
      } else {
        values = new ArrayList<JsonValue>(array.values);
      }
    }

    /**
     * Reads a JSON array from the given reader.
     * <p>
     * Characters are read in chunks and buffered internally, therefore wrapping an existing reader in
     * an additional <code>BufferedReader</code> does <strong>not</strong> improve reading
     * performance.
     * </p>
     *
     * @param reader the reader to read the JSON array from
     * @return the JSON array that has been read
     * @throws IOException                   if an I/O error occurs in the reader
     * @throws ParseException                if the input is not valid JSON
     * @throws UnsupportedOperationException if the input does not contain a JSON array
     * @deprecated Use {@link Json#parse(Reader)}{@link JsonValue#asArray() .asArray()} instead
     */
    @Deprecated
    public static JsonArray readFrom(Reader reader) throws IOException {
      return JsonValue.readFrom(reader).asArray();
    }

    /**
     * Reads a JSON array from the given string.
     *
     * @param string the string that contains the JSON array
     * @return the JSON array that has been read
     * @throws ParseException                if the input is not valid JSON
     * @throws UnsupportedOperationException if the input does not contain a JSON array
     * @deprecated Use {@link Json#parse(String)}{@link JsonValue#asArray() .asArray()} instead
     */
    @Deprecated
    public static JsonArray readFrom(String string) {
      return JsonValue.readFrom(string).asArray();
    }

    /**
     * Returns an unmodifiable wrapper for the specified JsonArray. This method allows to provide
     * read-only access to a JsonArray.
     * <p>
     * The returned JsonArray is backed by the given array and reflects subsequent changes. Attempts
     * to modify the returned JsonArray result in an <code>UnsupportedOperationException</code>.
     * </p>
     *
     * @param array the JsonArray for which an unmodifiable JsonArray is to be returned
     * @return an unmodifiable view of the specified JsonArray
     */
    public static JsonArray unmodifiableArray(JsonArray array) {
      return new JsonArray(array, true);
    }

    /**
     * Appends the JSON representation of the specified <code>int</code> value to the end of this
     * array.
     *
     * @param value the value to add to the array
     * @return the array itself, to enable method chaining
     */
    public JsonArray add(int value) {
      values.add(Json.value(value));
      return this;
    }

    /**
     * Appends the JSON representation of the specified <code>long</code> value to the end of this
     * array.
     *
     * @param value the value to add to the array
     * @return the array itself, to enable method chaining
     */
    public JsonArray add(long value) {
      values.add(Json.value(value));
      return this;
    }

    /**
     * Appends the JSON representation of the specified <code>float</code> value to the end of this
     * array.
     *
     * @param value the value to add to the array
     * @return the array itself, to enable method chaining
     */
    public JsonArray add(float value) {
      values.add(Json.value(value));
      return this;
    }

    /**
     * Appends the JSON representation of the specified <code>double</code> value to the end of this
     * array.
     *
     * @param value the value to add to the array
     * @return the array itself, to enable method chaining
     */
    public JsonArray add(double value) {
      values.add(Json.value(value));
      return this;
    }

    /**
     * Appends the JSON representation of the specified <code>boolean</code> value to the end of this
     * array.
     *
     * @param value the value to add to the array
     * @return the array itself, to enable method chaining
     */
    public JsonArray add(boolean value) {
      values.add(Json.value(value));
      return this;
    }

    /**
     * Appends the JSON representation of the specified string to the end of this array.
     *
     * @param value the string to add to the array
     * @return the array itself, to enable method chaining
     */
    public JsonArray add(String value) {
      values.add(Json.value(value));
      return this;
    }

    /**
     * Appends the specified JSON value to the end of this array.
     *
     * @param value the JsonValue to add to the array, must not be <code>null</code>
     * @return the array itself, to enable method chaining
     */
    public JsonArray add(JsonValue value) {
      if (value == null) {
        throw new NullPointerException("value is null");
      }
      values.add(value);
      return this;
    }

    /**
     * Replaces the element at the specified position in this array with the JSON representation of
     * the specified <code>int</code> value.
     *
     * @param index the index of the array element to replace
     * @param value the value to be stored at the specified array position
     * @return the array itself, to enable method chaining
     * @throws IndexOutOfBoundsException if the index is out of range, i.e. <code>index &lt; 0</code> or
     *                                   <code>index &gt;= size</code>
     */
    public JsonArray set(int index, int value) {
      values.set(index, Json.value(value));
      return this;
    }

    /**
     * Replaces the element at the specified position in this array with the JSON representation of
     * the specified <code>long</code> value.
     *
     * @param index the index of the array element to replace
     * @param value the value to be stored at the specified array position
     * @return the array itself, to enable method chaining
     * @throws IndexOutOfBoundsException if the index is out of range, i.e. <code>index &lt; 0</code> or
     *                                   <code>index &gt;= size</code>
     */
    public JsonArray set(int index, long value) {
      values.set(index, Json.value(value));
      return this;
    }

    /**
     * Replaces the element at the specified position in this array with the JSON representation of
     * the specified <code>float</code> value.
     *
     * @param index the index of the array element to replace
     * @param value the value to be stored at the specified array position
     * @return the array itself, to enable method chaining
     * @throws IndexOutOfBoundsException if the index is out of range, i.e. <code>index &lt; 0</code> or
     *                                   <code>index &gt;= size</code>
     */
    public JsonArray set(int index, float value) {
      values.set(index, Json.value(value));
      return this;
    }

    /**
     * Replaces the element at the specified position in this array with the JSON representation of
     * the specified <code>double</code> value.
     *
     * @param index the index of the array element to replace
     * @param value the value to be stored at the specified array position
     * @return the array itself, to enable method chaining
     * @throws IndexOutOfBoundsException if the index is out of range, i.e. <code>index &lt; 0</code> or
     *                                   <code>index &gt;= size</code>
     */
    public JsonArray set(int index, double value) {
      values.set(index, Json.value(value));
      return this;
    }

    /**
     * Replaces the element at the specified position in this array with the JSON representation of
     * the specified <code>boolean</code> value.
     *
     * @param index the index of the array element to replace
     * @param value the value to be stored at the specified array position
     * @return the array itself, to enable method chaining
     * @throws IndexOutOfBoundsException if the index is out of range, i.e. <code>index &lt; 0</code> or
     *                                   <code>index &gt;= size</code>
     */
    public JsonArray set(int index, boolean value) {
      values.set(index, Json.value(value));
      return this;
    }

    /**
     * Replaces the element at the specified position in this array with the JSON representation of
     * the specified string.
     *
     * @param index the index of the array element to replace
     * @param value the string to be stored at the specified array position
     * @return the array itself, to enable method chaining
     * @throws IndexOutOfBoundsException if the index is out of range, i.e. <code>index &lt; 0</code> or
     *                                   <code>index &gt;= size</code>
     */
    public JsonArray set(int index, String value) {
      values.set(index, Json.value(value));
      return this;
    }

    /**
     * Replaces the element at the specified position in this array with the specified JSON value.
     *
     * @param index the index of the array element to replace
     * @param value the value to be stored at the specified array position, must not be <code>null</code>
     * @return the array itself, to enable method chaining
     * @throws IndexOutOfBoundsException if the index is out of range, i.e. <code>index &lt; 0</code> or
     *                                   <code>index &gt;= size</code>
     */
    public JsonArray set(int index, JsonValue value) {
      if (value == null) {
        throw new NullPointerException("value is null");
      }
      values.set(index, value);
      return this;
    }

    /**
     * Removes the element at the specified index from this array.
     *
     * @param index the index of the element to remove
     * @return the array itself, to enable method chaining
     * @throws IndexOutOfBoundsException if the index is out of range, i.e. <code>index &lt; 0</code> or
     *                                   <code>index &gt;= size</code>
     */
    public JsonArray remove(int index) {
      values.remove(index);
      return this;
    }

    /**
     * Returns the number of elements in this array.
     *
     * @return the number of elements in this array
     */
    public int size() {
      return values.size();
    }

    /**
     * Returns <code>true</code> if this array contains no elements.
     *
     * @return <code>true</code> if this array contains no elements
     */
    public boolean isEmpty() {
      return values.isEmpty();
    }

    /**
     * Returns the value of the element at the specified position in this array.
     *
     * @param index the index of the array element to return
     * @return the value of the element at the specified position
     * @throws IndexOutOfBoundsException if the index is out of range, i.e. <code>index &lt; 0</code> or
     *                                   <code>index &gt;= size</code>
     */
    public JsonValue get(int index) {
      return values.get(index);
    }

    /**
     * Returns a list of the values in this array in document order. The returned list is backed by
     * this array and will reflect subsequent changes. It cannot be used to modify this array.
     * Attempts to modify the returned list will result in an exception.
     *
     * @return a list of the values in this array
     */
    public List<JsonValue> values() {
      return Collections.unmodifiableList(values);
    }

    /**
     * Returns an iterator over the values of this array in document order. The returned iterator
     * cannot be used to modify this array.
     *
     * @return an iterator over the values of this array
     */
    public Iterator<JsonValue> iterator() {
      final Iterator<JsonValue> iterator = values.iterator();
      return new Iterator<JsonValue>() {

        public boolean hasNext() {
          return iterator.hasNext();
        }

        public JsonValue next() {
          return iterator.next();
        }

        public void remove() {
          throw new UnsupportedOperationException();
        }
      };
    }

    @Override
    void write(JsonWriter writer) throws IOException {
      writer.writeArrayOpen();
      Iterator<JsonValue> iterator = iterator();
      if (iterator.hasNext()) {
        iterator.next().write(writer);
        while (iterator.hasNext()) {
          writer.writeArraySeparator();
          iterator.next().write(writer);
        }
      }
      writer.writeArrayClose();
    }

    @Override
    public boolean isArray() {
      return true;
    }

    @Override
    public JsonArray asArray() {
      return this;
    }

    @Override
    public int hashCode() {
      return values.hashCode();
    }

    /**
     * Indicates whether a given object is "equal to" this JsonArray. An object is considered equal
     * if it is also a <code>JsonArray</code> and both arrays contain the same list of values.
     * <p>
     * If two JsonArrays are equal, they will also produce the same JSON output.
     * </p>
     *
     * @param object the object to be compared with this JsonArray
     * @return <tt>true</tt> if the specified object is equal to this JsonArray, <code>false</code>
     * otherwise
     */
    @Override
    public boolean equals(Object object) {
      if (this == object) {
        return true;
      }
      if (object == null) {
        return false;
      }
      if (getClass() != object.getClass()) {
        return false;
      }
      JsonArray other = (JsonArray) object;
      return values.equals(other.values);
    }
  }


  /**
   * A handler for parser events. Instances of this class can be given to a {@link JsonParser}. The
   * parser will then call the methods of the given handler while reading the input.
   * <p>
   * The default implementations of these methods do nothing. Subclasses may override only those
   * methods they are interested in. They can use <code>getLocation()</code> to access the current
   * character position of the parser at any point. The <code>start*</code> methods will be called
   * while the location points to the first character of the parsed element. The <code>end*</code>
   * methods will be called while the location points to the character position that directly follows
   * the last character of the parsed element. Example:
   * </p>
   * <p>
   * <pre>
   * ["lorem ipsum"]
   *  ^            ^
   *  startString  endString
   * </pre>
   * <p>
   * Subclasses that build an object representation of the parsed JSON can return arbitrary handler
   * objects for JSON arrays and JSON objects in {@link #startArray()} and {@link #startObject()}.
   * These handler objects will then be provided in all subsequent parser events for this particular
   * array or object. They can be used to keep track the elements of a JSON array or object.
   * </p>
   *
   * @param <A> The type of handlers used for JSON arrays
   * @param <O> The type of handlers used for JSON objects
   * @see JsonParser
   */
  public static abstract class JsonHandler<A, O> {

    JsonParser parser;

    /**
     * Returns the current parser location.
     *
     * @return the current parser location
     */
    protected Location getLocation() {
      return parser.getLocation();
    }

    /**
     * Indicates the beginning of a <code>null</code> literal in the JSON input. This method will be
     * called when reading the first character of the literal.
     */
    public void startNull() {
    }

    /**
     * Indicates the end of a <code>null</code> literal in the JSON input. This method will be called
     * after reading the last character of the literal.
     */
    public void endNull() {
    }

    /**
     * Indicates the beginning of a boolean literal (<code>true</code> or <code>false</code>) in the
     * JSON input. This method will be called when reading the first character of the literal.
     */
    public void startBoolean() {
    }

    /**
     * Indicates the end of a boolean literal (<code>true</code> or <code>false</code>) in the JSON
     * input. This method will be called after reading the last character of the literal.
     *
     * @param value the parsed boolean value
     */
    public void endBoolean(boolean value) {
    }

    /**
     * Indicates the beginning of a string in the JSON input. This method will be called when reading
     * the opening double quote character (<code>'&quot;'</code>).
     */
    public void startString() {
    }

    /**
     * Indicates the end of a string in the JSON input. This method will be called after reading the
     * closing double quote character (<code>'&quot;'</code>).
     *
     * @param string the parsed string
     */
    public void endString(String string) {
    }

    /**
     * Indicates the beginning of a number in the JSON input. This method will be called when reading
     * the first character of the number.
     */
    public void startNumber() {
    }

    /**
     * Indicates the end of a number in the JSON input. This method will be called after reading the
     * last character of the number.
     *
     * @param string the parsed number string
     */
    public void endNumber(String string) {
    }

    /**
     * Indicates the beginning of an array in the JSON input. This method will be called when reading
     * the opening square bracket character (<code>'['</code>).
     * <p>
     * This method may return an object to handle subsequent parser events for this array. This array
     * handler will then be provided in all calls to {@link #startArrayValue(Object)
     * startArrayValue()}, {@link #endArrayValue(Object) endArrayValue()}, and
     * {@link #endArray(Object) endArray()} for this array.
     * </p>
     *
     * @return a handler for this array, or <code>null</code> if not needed
     */
    public A startArray() {
      return null;
    }

    /**
     * Indicates the end of an array in the JSON input. This method will be called after reading the
     * closing square bracket character (<code>']'</code>).
     *
     * @param array the array handler returned from {@link #startArray()}, or <code>null</code> if not
     *              provided
     */
    public void endArray(A array) {
    }

    /**
     * Indicates the beginning of an array element in the JSON input. This method will be called when
     * reading the first character of the element, just before the call to the <code>start</code>
     * method for the specific element type ({@link #startString()}, {@link #startNumber()}, etc.).
     *
     * @param array the array handler returned from {@link #startArray()}, or <code>null</code> if not
     *              provided
     */
    public void startArrayValue(A array) {
    }

    /**
     * Indicates the end of an array element in the JSON input. This method will be called after
     * reading the last character of the element value, just after the <code>end</code> method for the
     * specific element type (like {@link #endString(String) endString()}, {@link #endNumber(String)
     * endNumber()}, etc.).
     *
     * @param array the array handler returned from {@link #startArray()}, or <code>null</code> if not
     *              provided
     */
    public void endArrayValue(A array) {
    }

    /**
     * Indicates the beginning of an object in the JSON input. This method will be called when reading
     * the opening curly bracket character (<code>'{'</code>).
     * <p>
     * This method may return an object to handle subsequent parser events for this object. This
     * object handler will be provided in all calls to {@link #startObjectName(Object)
     * startObjectName()}, {@link #endObjectName(Object, String) endObjectName()},
     * {@link #startObjectValue(Object, String) startObjectValue()},
     * {@link #endObjectValue(Object, String) endObjectValue()}, and {@link #endObject(Object)
     * endObject()} for this object.
     * </p>
     *
     * @return a handler for this object, or <code>null</code> if not needed
     */
    public O startObject() {
      return null;
    }

    /**
     * Indicates the end of an object in the JSON input. This method will be called after reading the
     * closing curly bracket character (<code>'}'</code>).
     *
     * @param object the object handler returned from {@link #startObject()}, or null if not provided
     */
    public void endObject(O object) {
    }

    /**
     * Indicates the beginning of the name of an object member in the JSON input. This method will be
     * called when reading the opening quote character ('&quot;') of the member name.
     *
     * @param object the object handler returned from {@link #startObject()}, or <code>null</code> if not
     *               provided
     */
    public void startObjectName(O object) {
    }

    /**
     * Indicates the end of an object member name in the JSON input. This method will be called after
     * reading the closing quote character (<code>'"'</code>) of the member name.
     *
     * @param object the object handler returned from {@link #startObject()}, or null if not provided
     * @param name   the parsed member name
     */
    public void endObjectName(O object, String name) {
    }

    /**
     * Indicates the beginning of the name of an object member in the JSON input. This method will be
     * called when reading the opening quote character ('&quot;') of the member name.
     *
     * @param object the object handler returned from {@link #startObject()}, or <code>null</code> if not
     *               provided
     * @param name   the member name
     */
    public void startObjectValue(O object, String name) {
    }

    /**
     * Indicates the end of an object member value in the JSON input. This method will be called after
     * reading the last character of the member value, just after the <code>end</code> method for the
     * specific member type (like {@link #endString(String) endString()}, {@link #endNumber(String)
     * endNumber()}, etc.).
     *
     * @param object the object handler returned from {@link #startObject()}, or null if not provided
     * @param name   the parsed member name
     */
    public void endObjectValue(O object, String name) {
    }
  }

  @SuppressWarnings("serial") // use default serial UID
  static class JsonLiteral extends JsonValue {

    private final String value;
    private final boolean isNull;
    private final boolean isTrue;
    private final boolean isFalse;

    JsonLiteral(String value) {
      this.value = value;
      isNull = "null".equals(value);
      isTrue = "true".equals(value);
      isFalse = "false".equals(value);
    }

    @Override
    void write(JsonWriter writer) throws IOException {
      writer.writeLiteral(value);
    }

    @Override
    public String toString() {
      return value;
    }

    @Override
    public int hashCode() {
      return value.hashCode();
    }

    @Override
    public boolean isNull() {
      return isNull;
    }

    @Override
    public boolean isTrue() {
      return isTrue;
    }

    @Override
    public boolean isFalse() {
      return isFalse;
    }

    @Override
    public boolean isBoolean() {
      return isTrue || isFalse;
    }

    @Override
    public boolean asBoolean() {
      return isNull ? super.asBoolean() : isTrue;
    }

    @Override
    public boolean equals(Object object) {
      if (this == object) {
        return true;
      }
      if (object == null) {
        return false;
      }
      if (getClass() != object.getClass()) {
        return false;
      }
      JsonLiteral other = (JsonLiteral) object;
      return value.equals(other.value);
    }
  }


  @SuppressWarnings("serial") // use default serial UID
  static class JsonNumber extends JsonValue {

    private final String string;

    JsonNumber(String string) {
      if (string == null) {
        throw new NullPointerException("string is null");
      }
      this.string = string;
    }

    @Override
    public String toString() {
      return string;
    }

    @Override
    void write(JsonWriter writer) throws IOException {
      writer.writeNumber(string);
    }

    @Override
    public boolean isNumber() {
      return true;
    }

    @Override
    public int asInt() {
      return Integer.parseInt(string, 10);
    }

    @Override
    public long asLong() {
      return Long.parseLong(string, 10);
    }

    @Override
    public float asFloat() {
      return Float.parseFloat(string);
    }

    @Override
    public double asDouble() {
      return Double.parseDouble(string);
    }

    @Override
    public int hashCode() {
      return string.hashCode();
    }

    @Override
    public boolean equals(Object object) {
      if (this == object) {
        return true;
      }
      if (object == null) {
        return false;
      }
      if (getClass() != object.getClass()) {
        return false;
      }
      JsonNumber other = (JsonNumber) object;
      return string.equals(other.string);
    }
  }


  /**
   * Represents a JSON object, a set of name/value pairs, where the names are strings and the values
   * are JSON values.
   * <p>
   * Members can be added using the <code>add(String, ...)</code> methods which accept instances of
   * {@link JsonValue}, strings, primitive numbers, and boolean values. To modify certain values of an
   * object, use the <code>set(String, ...)</code> methods. Please note that the <code>add</code>
   * methods are faster than <code>set</code> as they do not search for existing members. On the other
   * hand, the <code>add</code> methods do not prevent adding multiple members with the same name.
   * Duplicate names are discouraged but not prohibited by JSON.
   * </p>
   * <p>
   * Members can be accessed by their name using {@link #get(String)}. A list of all names can be
   * obtained from the method {@link #names()}. This class also supports iterating over the members in
   * document order using an {@link #iterator()} or an enhanced for loop:
   * </p>
   * <pre>
   * for (Member member : jsonObject) {
   *   String name = member.getName();
   *   JsonValue value = member.getValue();
   *   ...
   * }
   * </pre>
   * <p>
   * Even though JSON objects are unordered by definition, instances of this class preserve the order
   * of members to allow processing in document order and to guarantee a predictable output.
   * </p>
   * <p>
   * Note that this class is <strong>not thread-safe</strong>. If multiple threads access a
   * <code>JsonObject</code> instance concurrently, while at least one of these threads modifies the
   * contents of this object, access to the instance must be synchronized externally. Failure to do so
   * may lead to an inconsistent state.
   * </p>
   * <p>
   * This class is <strong>not supposed to be extended</strong> by clients.
   * </p>
   */
  @SuppressWarnings("serial") // use default serial UID
  public static class JsonObject extends JsonValue implements Iterable<JsonObject.Member> {

    private final List<String> names;
    private final List<JsonValue> values;
    private transient HashIndexTable table;

    /**
     * Creates a new empty JsonObject.
     */
    public JsonObject() {
      names = new ArrayList<String>();
      values = new ArrayList<JsonValue>();
      table = new HashIndexTable();
    }

    /**
     * Creates a new JsonObject, initialized with the contents of the specified JSON object.
     *
     * @param object the JSON object to get the initial contents from, must not be <code>null</code>
     */
    public JsonObject(JsonObject object) {
      this(object, false);
    }

    private JsonObject(JsonObject object, boolean unmodifiable) {
      if (object == null) {
        throw new NullPointerException("object is null");
      }
      if (unmodifiable) {
        names = Collections.unmodifiableList(object.names);
        values = Collections.unmodifiableList(object.values);
      } else {
        names = new ArrayList<String>(object.names);
        values = new ArrayList<JsonValue>(object.values);
      }
      table = new HashIndexTable();
      updateHashIndex();
    }

    /**
     * Reads a JSON object from the given reader.
     * <p>
     * Characters are read in chunks and buffered internally, therefore wrapping an existing reader in
     * an additional <code>BufferedReader</code> does <strong>not</strong> improve reading
     * performance.
     * </p>
     *
     * @param reader the reader to read the JSON object from
     * @return the JSON object that has been read
     * @throws IOException                   if an I/O error occurs in the reader
     * @throws ParseException                if the input is not valid JSON
     * @throws UnsupportedOperationException if the input does not contain a JSON object
     * @deprecated Use {@link Json#parse(Reader)}{@link JsonValue#asObject() .asObject()} instead
     */
    @Deprecated
    public static JsonObject readFrom(Reader reader) throws IOException {
      return JsonValue.readFrom(reader).asObject();
    }

    /**
     * Reads a JSON object from the given string.
     *
     * @param string the string that contains the JSON object
     * @return the JSON object that has been read
     * @throws ParseException                if the input is not valid JSON
     * @throws UnsupportedOperationException if the input does not contain a JSON object
     * @deprecated Use {@link Json#parse(String)}{@link JsonValue#asObject() .asObject()} instead
     */
    @Deprecated
    public static JsonObject readFrom(String string) {
      return JsonValue.readFrom(string).asObject();
    }

    /**
     * Returns an unmodifiable JsonObject for the specified one. This method allows to provide
     * read-only access to a JsonObject.
     * <p>
     * The returned JsonObject is backed by the given object and reflect changes that happen to it.
     * Attempts to modify the returned JsonObject result in an
     * <code>UnsupportedOperationException</code>.
     * </p>
     *
     * @param object the JsonObject for which an unmodifiable JsonObject is to be returned
     * @return an unmodifiable view of the specified JsonObject
     */
    public static JsonObject unmodifiableObject(JsonObject object) {
      return new JsonObject(object, true);
    }

    /**
     * Appends a new member to the end of this object, with the specified name and the JSON
     * representation of the specified <code>int</code> value.
     * <p>
     * This method <strong>does not prevent duplicate names</strong>. Calling this method with a name
     * that already exists in the object will append another member with the same name. In order to
     * replace existing members, use the method <code>set(name, value)</code> instead. However,
     * <strong> <em>add</em> is much faster than <em>set</em></strong> (because it does not need to
     * search for existing members). Therefore <em>add</em> should be preferred when constructing new
     * objects.
     * </p>
     *
     * @param name  the name of the member to add
     * @param value the value of the member to add
     * @return the object itself, to enable method chaining
     */
    public JsonObject add(String name, int value) {
      add(name, Json.value(value));
      return this;
    }

    /**
     * Appends a new member to the end of this object, with the specified name and the JSON
     * representation of the specified <code>long</code> value.
     * <p>
     * This method <strong>does not prevent duplicate names</strong>. Calling this method with a name
     * that already exists in the object will append another member with the same name. In order to
     * replace existing members, use the method <code>set(name, value)</code> instead. However,
     * <strong> <em>add</em> is much faster than <em>set</em></strong> (because it does not need to
     * search for existing members). Therefore <em>add</em> should be preferred when constructing new
     * objects.
     * </p>
     *
     * @param name  the name of the member to add
     * @param value the value of the member to add
     * @return the object itself, to enable method chaining
     */
    public JsonObject add(String name, long value) {
      add(name, Json.value(value));
      return this;
    }

    /**
     * Appends a new member to the end of this object, with the specified name and the JSON
     * representation of the specified <code>float</code> value.
     * <p>
     * This method <strong>does not prevent duplicate names</strong>. Calling this method with a name
     * that already exists in the object will append another member with the same name. In order to
     * replace existing members, use the method <code>set(name, value)</code> instead. However,
     * <strong> <em>add</em> is much faster than <em>set</em></strong> (because it does not need to
     * search for existing members). Therefore <em>add</em> should be preferred when constructing new
     * objects.
     * </p>
     *
     * @param name  the name of the member to add
     * @param value the value of the member to add
     * @return the object itself, to enable method chaining
     */
    public JsonObject add(String name, float value) {
      add(name, Json.value(value));
      return this;
    }

    /**
     * Appends a new member to the end of this object, with the specified name and the JSON
     * representation of the specified <code>double</code> value.
     * <p>
     * This method <strong>does not prevent duplicate names</strong>. Calling this method with a name
     * that already exists in the object will append another member with the same name. In order to
     * replace existing members, use the method <code>set(name, value)</code> instead. However,
     * <strong> <em>add</em> is much faster than <em>set</em></strong> (because it does not need to
     * search for existing members). Therefore <em>add</em> should be preferred when constructing new
     * objects.
     * </p>
     *
     * @param name  the name of the member to add
     * @param value the value of the member to add
     * @return the object itself, to enable method chaining
     */
    public JsonObject add(String name, double value) {
      add(name, Json.value(value));
      return this;
    }

    /**
     * Appends a new member to the end of this object, with the specified name and the JSON
     * representation of the specified <code>boolean</code> value.
     * <p>
     * This method <strong>does not prevent duplicate names</strong>. Calling this method with a name
     * that already exists in the object will append another member with the same name. In order to
     * replace existing members, use the method <code>set(name, value)</code> instead. However,
     * <strong> <em>add</em> is much faster than <em>set</em></strong> (because it does not need to
     * search for existing members). Therefore <em>add</em> should be preferred when constructing new
     * objects.
     * </p>
     *
     * @param name  the name of the member to add
     * @param value the value of the member to add
     * @return the object itself, to enable method chaining
     */
    public JsonObject add(String name, boolean value) {
      add(name, Json.value(value));
      return this;
    }

    /**
     * Appends a new member to the end of this object, with the specified name and the JSON
     * representation of the specified string.
     * <p>
     * This method <strong>does not prevent duplicate names</strong>. Calling this method with a name
     * that already exists in the object will append another member with the same name. In order to
     * replace existing members, use the method <code>set(name, value)</code> instead. However,
     * <strong> <em>add</em> is much faster than <em>set</em></strong> (because it does not need to
     * search for existing members). Therefore <em>add</em> should be preferred when constructing new
     * objects.
     * </p>
     *
     * @param name  the name of the member to add
     * @param value the value of the member to add
     * @return the object itself, to enable method chaining
     */
    public JsonObject add(String name, String value) {
      add(name, Json.value(value));
      return this;
    }

    /**
     * Appends a new member to the end of this object, with the specified name and the specified JSON
     * value.
     * <p>
     * This method <strong>does not prevent duplicate names</strong>. Calling this method with a name
     * that already exists in the object will append another member with the same name. In order to
     * replace existing members, use the method <code>set(name, value)</code> instead. However,
     * <strong> <em>add</em> is much faster than <em>set</em></strong> (because it does not need to
     * search for existing members). Therefore <em>add</em> should be preferred when constructing new
     * objects.
     * </p>
     *
     * @param name  the name of the member to add
     * @param value the value of the member to add, must not be <code>null</code>
     * @return the object itself, to enable method chaining
     */
    public JsonObject add(String name, JsonValue value) {
      if (name == null) {
        throw new NullPointerException("name is null");
      }
      if (value == null) {
        throw new NullPointerException("value is null");
      }
      table.add(name, names.size());
      names.add(name);
      values.add(value);
      return this;
    }

    /**
     * Sets the value of the member with the specified name to the JSON representation of the
     * specified <code>int</code> value. If this object does not contain a member with this name, a
     * new member is added at the end of the object. If this object contains multiple members with
     * this name, only the last one is changed.
     * <p>
     * This method should <strong>only be used to modify existing objects</strong>. To fill a new
     * object with members, the method <code>add(name, value)</code> should be preferred which is much
     * faster (as it does not need to search for existing members).
     * </p>
     *
     * @param name  the name of the member to replace
     * @param value the value to set to the member
     * @return the object itself, to enable method chaining
     */
    public JsonObject set(String name, int value) {
      set(name, Json.value(value));
      return this;
    }

    /**
     * Sets the value of the member with the specified name to the JSON representation of the
     * specified <code>long</code> value. If this object does not contain a member with this name, a
     * new member is added at the end of the object. If this object contains multiple members with
     * this name, only the last one is changed.
     * <p>
     * This method should <strong>only be used to modify existing objects</strong>. To fill a new
     * object with members, the method <code>add(name, value)</code> should be preferred which is much
     * faster (as it does not need to search for existing members).
     * </p>
     *
     * @param name  the name of the member to replace
     * @param value the value to set to the member
     * @return the object itself, to enable method chaining
     */
    public JsonObject set(String name, long value) {
      set(name, Json.value(value));
      return this;
    }

    /**
     * Sets the value of the member with the specified name to the JSON representation of the
     * specified <code>float</code> value. If this object does not contain a member with this name, a
     * new member is added at the end of the object. If this object contains multiple members with
     * this name, only the last one is changed.
     * <p>
     * This method should <strong>only be used to modify existing objects</strong>. To fill a new
     * object with members, the method <code>add(name, value)</code> should be preferred which is much
     * faster (as it does not need to search for existing members).
     * </p>
     *
     * @param name  the name of the member to add
     * @param value the value of the member to add
     * @return the object itself, to enable method chaining
     */
    public JsonObject set(String name, float value) {
      set(name, Json.value(value));
      return this;
    }

    /**
     * Sets the value of the member with the specified name to the JSON representation of the
     * specified <code>double</code> value. If this object does not contain a member with this name, a
     * new member is added at the end of the object. If this object contains multiple members with
     * this name, only the last one is changed.
     * <p>
     * This method should <strong>only be used to modify existing objects</strong>. To fill a new
     * object with members, the method <code>add(name, value)</code> should be preferred which is much
     * faster (as it does not need to search for existing members).
     * </p>
     *
     * @param name  the name of the member to add
     * @param value the value of the member to add
     * @return the object itself, to enable method chaining
     */
    public JsonObject set(String name, double value) {
      set(name, Json.value(value));
      return this;
    }

    /**
     * Sets the value of the member with the specified name to the JSON representation of the
     * specified <code>boolean</code> value. If this object does not contain a member with this name,
     * a new member is added at the end of the object. If this object contains multiple members with
     * this name, only the last one is changed.
     * <p>
     * This method should <strong>only be used to modify existing objects</strong>. To fill a new
     * object with members, the method <code>add(name, value)</code> should be preferred which is much
     * faster (as it does not need to search for existing members).
     * </p>
     *
     * @param name  the name of the member to add
     * @param value the value of the member to add
     * @return the object itself, to enable method chaining
     */
    public JsonObject set(String name, boolean value) {
      set(name, Json.value(value));
      return this;
    }

    /**
     * Sets the value of the member with the specified name to the JSON representation of the
     * specified string. If this object does not contain a member with this name, a new member is
     * added at the end of the object. If this object contains multiple members with this name, only
     * the last one is changed.
     * <p>
     * This method should <strong>only be used to modify existing objects</strong>. To fill a new
     * object with members, the method <code>add(name, value)</code> should be preferred which is much
     * faster (as it does not need to search for existing members).
     * </p>
     *
     * @param name  the name of the member to add
     * @param value the value of the member to add
     * @return the object itself, to enable method chaining
     */
    public JsonObject set(String name, String value) {
      set(name, Json.value(value));
      return this;
    }

    /**
     * Sets the value of the member with the specified name to the specified JSON value. If this
     * object does not contain a member with this name, a new member is added at the end of the
     * object. If this object contains multiple members with this name, only the last one is changed.
     * <p>
     * This method should <strong>only be used to modify existing objects</strong>. To fill a new
     * object with members, the method <code>add(name, value)</code> should be preferred which is much
     * faster (as it does not need to search for existing members).
     * </p>
     *
     * @param name  the name of the member to add
     * @param value the value of the member to add, must not be <code>null</code>
     * @return the object itself, to enable method chaining
     */
    public JsonObject set(String name, JsonValue value) {
      if (name == null) {
        throw new NullPointerException("name is null");
      }
      if (value == null) {
        throw new NullPointerException("value is null");
      }
      int index = indexOf(name);
      if (index != -1) {
        values.set(index, value);
      } else {
        table.add(name, names.size());
        names.add(name);
        values.add(value);
      }
      return this;
    }

    /**
     * Removes a member with the specified name from this object. If this object contains multiple
     * members with the given name, only the last one is removed. If this object does not contain a
     * member with the specified name, the object is not modified.
     *
     * @param name the name of the member to remove
     * @return the object itself, to enable method chaining
     */
    public JsonObject remove(String name) {
      if (name == null) {
        throw new NullPointerException("name is null");
      }
      int index = indexOf(name);
      if (index != -1) {
        table.remove(index);
        names.remove(index);
        values.remove(index);
      }
      return this;
    }

    /**
     * Copies all members of the specified object into this object. When the specified object contains
     * members with names that also exist in this object, the existing values in this object will be
     * replaced by the corresponding values in the specified object.
     *
     * @param object the object to merge
     * @return the object itself, to enable method chaining
     */
    public JsonObject merge(JsonObject object) {
      if (object == null) {
        throw new NullPointerException("object is null");
      }
      for (Member member : object) {
        this.set(member.name, member.value);
      }
      return this;
    }

    /**
     * Returns the value of the member with the specified name in this object. If this object contains
     * multiple members with the given name, this method will return the last one.
     *
     * @param name the name of the member whose value is to be returned
     * @return the value of the last member with the specified name, or <code>null</code> if this
     * object does not contain a member with that name
     */
    public JsonValue get(String name) {
      if (name == null) {
        throw new NullPointerException("name is null");
      }
      int index = indexOf(name);
      return index != -1 ? values.get(index) : null;
    }

    /**
     * Returns the <code>int</code> value of the member with the specified name in this object. If
     * this object does not contain a member with this name, the given default value is returned. If
     * this object contains multiple members with the given name, the last one will be picked. If this
     * member's value does not represent a JSON number or if it cannot be interpreted as Java
     * <code>int</code>, an exception is thrown.
     *
     * @param name         the name of the member whose value is to be returned
     * @param defaultValue the value to be returned if the requested member is missing
     * @return the value of the last member with the specified name, or the given default value if
     * this object does not contain a member with that name
     */
    public int getInt(String name, int defaultValue) {
      JsonValue value = get(name);
      return value != null ? value.asInt() : defaultValue;
    }

    /**
     * Returns the <code>long</code> value of the member with the specified name in this object. If
     * this object does not contain a member with this name, the given default value is returned. If
     * this object contains multiple members with the given name, the last one will be picked. If this
     * member's value does not represent a JSON number or if it cannot be interpreted as Java
     * <code>long</code>, an exception is thrown.
     *
     * @param name         the name of the member whose value is to be returned
     * @param defaultValue the value to be returned if the requested member is missing
     * @return the value of the last member with the specified name, or the given default value if
     * this object does not contain a member with that name
     */
    public long getLong(String name, long defaultValue) {
      JsonValue value = get(name);
      return value != null ? value.asLong() : defaultValue;
    }

    /**
     * Returns the <code>float</code> value of the member with the specified name in this object. If
     * this object does not contain a member with this name, the given default value is returned. If
     * this object contains multiple members with the given name, the last one will be picked. If this
     * member's value does not represent a JSON number or if it cannot be interpreted as Java
     * <code>float</code>, an exception is thrown.
     *
     * @param name         the name of the member whose value is to be returned
     * @param defaultValue the value to be returned if the requested member is missing
     * @return the value of the last member with the specified name, or the given default value if
     * this object does not contain a member with that name
     */
    public float getFloat(String name, float defaultValue) {
      JsonValue value = get(name);
      return value != null ? value.asFloat() : defaultValue;
    }

    /**
     * Returns the <code>double</code> value of the member with the specified name in this object. If
     * this object does not contain a member with this name, the given default value is returned. If
     * this object contains multiple members with the given name, the last one will be picked. If this
     * member's value does not represent a JSON number or if it cannot be interpreted as Java
     * <code>double</code>, an exception is thrown.
     *
     * @param name         the name of the member whose value is to be returned
     * @param defaultValue the value to be returned if the requested member is missing
     * @return the value of the last member with the specified name, or the given default value if
     * this object does not contain a member with that name
     */
    public double getDouble(String name, double defaultValue) {
      JsonValue value = get(name);
      return value != null ? value.asDouble() : defaultValue;
    }

    /**
     * Returns the <code>boolean</code> value of the member with the specified name in this object. If
     * this object does not contain a member with this name, the given default value is returned. If
     * this object contains multiple members with the given name, the last one will be picked. If this
     * member's value does not represent a JSON <code>true</code> or <code>false</code> value, an
     * exception is thrown.
     *
     * @param name         the name of the member whose value is to be returned
     * @param defaultValue the value to be returned if the requested member is missing
     * @return the value of the last member with the specified name, or the given default value if
     * this object does not contain a member with that name
     */
    public boolean getBoolean(String name, boolean defaultValue) {
      JsonValue value = get(name);
      return value != null ? value.asBoolean() : defaultValue;
    }

    /**
     * Returns the <code>String</code> value of the member with the specified name in this object. If
     * this object does not contain a member with this name, the given default value is returned. If
     * this object contains multiple members with the given name, the last one is picked. If this
     * member's value does not represent a JSON string, an exception is thrown.
     *
     * @param name         the name of the member whose value is to be returned
     * @param defaultValue the value to be returned if the requested member is missing
     * @return the value of the last member with the specified name, or the given default value if
     * this object does not contain a member with that name
     */
    public String getString(String name, String defaultValue) {
      JsonValue value = get(name);
      return value != null ? value.asString() : defaultValue;
    }

    /**
     * Returns the number of members (name/value pairs) in this object.
     *
     * @return the number of members in this object
     */
    public int size() {
      return names.size();
    }

    /**
     * Returns <code>true</code> if this object contains no members.
     *
     * @return <code>true</code> if this object contains no members
     */
    public boolean isEmpty() {
      return names.isEmpty();
    }

    /**
     * Returns a list of the names in this object in document order. The returned list is backed by
     * this object and will reflect subsequent changes. It cannot be used to modify this object.
     * Attempts to modify the returned list will result in an exception.
     *
     * @return a list of the names in this object
     */
    public List<String> names() {
      return Collections.unmodifiableList(names);
    }

    /**
     * Returns an iterator over the members of this object in document order. The returned iterator
     * cannot be used to modify this object.
     *
     * @return an iterator over the members of this object
     */
    public Iterator<Member> iterator() {
      final Iterator<String> namesIterator = names.iterator();
      final Iterator<JsonValue> valuesIterator = values.iterator();
      return new Iterator<JsonObject.Member>() {

        public boolean hasNext() {
          return namesIterator.hasNext();
        }

        public Member next() {
          String name = namesIterator.next();
          JsonValue value = valuesIterator.next();
          return new Member(name, value);
        }

        public void remove() {
          throw new UnsupportedOperationException();
        }

      };
    }

    @Override
    void write(JsonWriter writer) throws IOException {
      writer.writeObjectOpen();
      Iterator<String> namesIterator = names.iterator();
      Iterator<JsonValue> valuesIterator = values.iterator();
      if (namesIterator.hasNext()) {
        writer.writeMemberName(namesIterator.next());
        writer.writeMemberSeparator();
        valuesIterator.next().write(writer);
        while (namesIterator.hasNext()) {
          writer.writeObjectSeparator();
          writer.writeMemberName(namesIterator.next());
          writer.writeMemberSeparator();
          valuesIterator.next().write(writer);
        }
      }
      writer.writeObjectClose();
    }

    @Override
    public boolean isObject() {
      return true;
    }

    @Override
    public JsonObject asObject() {
      return this;
    }

    @Override
    public int hashCode() {
      int result = 1;
      result = 31 * result + names.hashCode();
      result = 31 * result + values.hashCode();
      return result;
    }

    @Override
    public boolean equals(Object obj) {
      if (this == obj) {
        return true;
      }
      if (obj == null) {
        return false;
      }
      if (getClass() != obj.getClass()) {
        return false;
      }
      JsonObject other = (JsonObject) obj;
      return names.equals(other.names) && values.equals(other.values);
    }

    int indexOf(String name) {
      int index = table.get(name);
      if (index != -1 && name.equals(names.get(index))) {
        return index;
      }
      return names.lastIndexOf(name);
    }

    private synchronized void readObject(ObjectInputStream inputStream)
        throws IOException, ClassNotFoundException {
      inputStream.defaultReadObject();
      table = new HashIndexTable();
      updateHashIndex();
    }

    private void updateHashIndex() {
      int size = names.size();
      for (int i = 0; i < size; i++) {
        table.add(names.get(i), i);
      }
    }

    /**
     * Represents a member of a JSON object, a pair of a name and a value.
     */
    public static class Member {

      private final String name;
      private final JsonValue value;

      Member(String name, JsonValue value) {
        this.name = name;
        this.value = value;
      }

      /**
       * Returns the name of this member.
       *
       * @return the name of this member, never <code>null</code>
       */
      public String getName() {
        return name;
      }

      /**
       * Returns the value of this member.
       *
       * @return the value of this member, never <code>null</code>
       */
      public JsonValue getValue() {
        return value;
      }

      @Override
      public int hashCode() {
        int result = 1;
        result = 31 * result + name.hashCode();
        result = 31 * result + value.hashCode();
        return result;
      }

      /**
       * Indicates whether a given object is "equal to" this JsonObject. An object is considered equal
       * if it is also a <code>JsonObject</code> and both objects contain the same members <em>in
       * the same order</em>.
       * <p>
       * If two JsonObjects are equal, they will also produce the same JSON output.
       * </p>
       *
       * @param object the object to be compared with this JsonObject
       * @return <tt>true</tt> if the specified object is equal to this JsonObject, <code>false</code>
       * otherwise
       */
      @Override
      public boolean equals(Object object) {
        if (this == object) {
          return true;
        }
        if (object == null) {
          return false;
        }
        if (getClass() != object.getClass()) {
          return false;
        }
        Member other = (Member) object;
        return name.equals(other.name) && value.equals(other.value);
      }

    }

    static class HashIndexTable {

      private final byte[] hashTable = new byte[32]; // must be a power of two

      public HashIndexTable() {
      }

      public HashIndexTable(HashIndexTable original) {
        System.arraycopy(original.hashTable, 0, hashTable, 0, hashTable.length);
      }

      void add(String name, int index) {
        int slot = hashSlotFor(name);
        if (index < 0xff) {
          // increment by 1, 0 stands for empty
          hashTable[slot] = (byte) (index + 1);
        } else {
          hashTable[slot] = 0;
        }
      }

      void remove(int index) {
        for (int i = 0; i < hashTable.length; i++) {
          if (hashTable[i] == index + 1) {
            hashTable[i] = 0;
          } else if (hashTable[i] > index + 1) {
            hashTable[i]--;
          }
        }
      }

      int get(Object name) {
        int slot = hashSlotFor(name);
        // subtract 1, 0 stands for empty
        return (hashTable[slot] & 0xff) - 1;
      }

      private int hashSlotFor(Object element) {
        return element.hashCode() & hashTable.length - 1;
      }
    }
  }

  /**
   * A streaming parser for JSON text. The parser reports all events to a given handler.
   */
  public static class JsonParser {

    private static final int MAX_NESTING_LEVEL = 1000;
    private static final int MIN_BUFFER_SIZE = 10;
    private static final int DEFAULT_BUFFER_SIZE = 1024;

    private final JsonHandler<Object, Object> handler;
    private Reader reader;
    private char[] buffer;
    private int bufferOffset;
    private int index;
    private int fill;
    private int line;
    private int lineOffset;
    private int current;
    private StringBuilder captureBuffer;
    private int captureStart;
    private int nestingLevel;

  /*
   * |                      bufferOffset
   *                        v
   * [a|b|c|d|e|f|g|h|i|j|k|l|m|n|o|p|q|r|s|t]        < input
   *                       [l|m|n|o|p|q|r|s|t|?|?]    < buffer
   *                          ^               ^
   *                       |  index           fill
   */

    /**
     * Creates a new JsonParser with the given handler. The parser will report all parser events to
     * this handler.
     *
     * @param handler the handler to process parser events
     */
    @SuppressWarnings("unchecked")
    public JsonParser(JsonHandler<?, ?> handler) {
      if (handler == null) {
        throw new NullPointerException("handler is null");
      }
      this.handler = (JsonHandler<Object, Object>) handler;
      handler.parser = this;
    }

    /**
     * Parses the given input string. The input must contain a valid JSON value, optionally padded
     * with whitespace.
     *
     * @param string the input string, must be valid JSON
     * @throws ParseException if the input is not valid JSON
     */
    public void parse(String string) {
      if (string == null) {
        throw new NullPointerException("string is null");
      }
      int bufferSize = Math.max(MIN_BUFFER_SIZE, Math.min(DEFAULT_BUFFER_SIZE, string.length()));
      try {
        parse(new StringReader(string), bufferSize);
      } catch (IOException exception) {
        // StringReader does not throw IOException
        throw new RuntimeException(exception);
      }
    }

    /**
     * Reads the entire input from the given reader and parses it as JSON. The input must contain a
     * valid JSON value, optionally padded with whitespace.
     * <p>
     * Characters are read in chunks into a default-sized input buffer. Hence, wrapping a reader in an
     * additional <code>BufferedReader</code> likely won't improve reading performance.
     * </p>
     *
     * @param reader the reader to read the input from
     * @throws IOException    if an I/O error occurs in the reader
     * @throws ParseException if the input is not valid JSON
     */
    public void parse(Reader reader) throws IOException {
      parse(reader, DEFAULT_BUFFER_SIZE);
    }

    /**
     * Reads the entire input from the given reader and parses it as JSON. The input must contain a
     * valid JSON value, optionally padded with whitespace.
     * <p>
     * Characters are read in chunks into an input buffer of the given size. Hence, wrapping a reader
     * in an additional <code>BufferedReader</code> likely won't improve reading performance.
     * </p>
     *
     * @param reader     the reader to read the input from
     * @param buffersize the size of the input buffer in chars
     * @throws IOException    if an I/O error occurs in the reader
     * @throws ParseException if the input is not valid JSON
     */
    public void parse(Reader reader, int buffersize) throws IOException {
      if (reader == null) {
        throw new NullPointerException("reader is null");
      }
      if (buffersize <= 0) {
        throw new IllegalArgumentException("buffersize is zero or negative");
      }
      this.reader = reader;
      buffer = new char[buffersize];
      bufferOffset = 0;
      index = 0;
      fill = 0;
      line = 1;
      lineOffset = 0;
      current = 0;
      captureStart = -1;
      read();
      skipWhiteSpace();
      readValue();
      skipWhiteSpace();
      if (!isEndOfText()) {
        throw error("Unexpected character");
      }
    }

    private void readValue() throws IOException {
      switch (current) {
        case 'n':
          readNull();
          break;
        case 't':
          readTrue();
          break;
        case 'f':
          readFalse();
          break;
        case '"':
          readString();
          break;
        case '[':
          readArray();
          break;
        case '{':
          readObject();
          break;
        case '-':
        case '0':
        case '1':
        case '2':
        case '3':
        case '4':
        case '5':
        case '6':
        case '7':
        case '8':
        case '9':
          readNumber();
          break;
        default:
          throw expected("value");
      }
    }

    private void readArray() throws IOException {
      Object array = handler.startArray();
      read();
      if (++nestingLevel > MAX_NESTING_LEVEL) {
        throw error("Nesting too deep");
      }
      skipWhiteSpace();
      if (readChar(']')) {
        nestingLevel--;
        handler.endArray(array);
        return;
      }
      do {
        skipWhiteSpace();
        handler.startArrayValue(array);
        readValue();
        handler.endArrayValue(array);
        skipWhiteSpace();
      } while (readChar(','));
      if (!readChar(']')) {
        throw expected("',' or ']'");
      }
      nestingLevel--;
      handler.endArray(array);
    }

    private void readObject() throws IOException {
      Object object = handler.startObject();
      read();
      if (++nestingLevel > MAX_NESTING_LEVEL) {
        throw error("Nesting too deep");
      }
      skipWhiteSpace();
      if (readChar('}')) {
        nestingLevel--;
        handler.endObject(object);
        return;
      }
      do {
        skipWhiteSpace();
        handler.startObjectName(object);
        String name = readName();
        handler.endObjectName(object, name);
        skipWhiteSpace();
        if (!readChar(':')) {
          throw expected("':'");
        }
        skipWhiteSpace();
        handler.startObjectValue(object, name);
        readValue();
        handler.endObjectValue(object, name);
        skipWhiteSpace();
      } while (readChar(','));
      if (!readChar('}')) {
        throw expected("',' or '}'");
      }
      nestingLevel--;
      handler.endObject(object);
    }

    private String readName() throws IOException {
      if (current != '"') {
        throw expected("name");
      }
      return readStringInternal();
    }

    private void readNull() throws IOException {
      handler.startNull();
      read();
      readRequiredChar('u');
      readRequiredChar('l');
      readRequiredChar('l');
      handler.endNull();
    }

    private void readTrue() throws IOException {
      handler.startBoolean();
      read();
      readRequiredChar('r');
      readRequiredChar('u');
      readRequiredChar('e');
      handler.endBoolean(true);
    }

    private void readFalse() throws IOException {
      handler.startBoolean();
      read();
      readRequiredChar('a');
      readRequiredChar('l');
      readRequiredChar('s');
      readRequiredChar('e');
      handler.endBoolean(false);
    }

    private void readRequiredChar(char ch) throws IOException {
      if (!readChar(ch)) {
        throw expected("'" + ch + "'");
      }
    }

    private void readString() throws IOException {
      handler.startString();
      handler.endString(readStringInternal());
    }

    private String readStringInternal() throws IOException {
      read();
      startCapture();
      while (current != '"') {
        if (current == '\\') {
          pauseCapture();
          readEscape();
          startCapture();
        } else if (current < 0x20) {
          throw expected("valid string character");
        } else {
          read();
        }
      }
      String string = endCapture();
      read();
      return string;
    }

    private void readEscape() throws IOException {
      read();
      switch (current) {
        case '"':
        case '/':
        case '\\':
          captureBuffer.append((char) current);
          break;
        case 'b':
          captureBuffer.append('\b');
          break;
        case 'f':
          captureBuffer.append('\f');
          break;
        case 'n':
          captureBuffer.append('\n');
          break;
        case 'r':
          captureBuffer.append('\r');
          break;
        case 't':
          captureBuffer.append('\t');
          break;
        case 'u':
          char[] hexChars = new char[4];
          for (int i = 0; i < 4; i++) {
            read();
            if (!isHexDigit()) {
              throw expected("hexadecimal digit");
            }
            hexChars[i] = (char) current;
          }
          captureBuffer.append((char) Integer.parseInt(new String(hexChars), 16));
          break;
        default:
          throw expected("valid escape sequence");
      }
      read();
    }

    private void readNumber() throws IOException {
      handler.startNumber();
      startCapture();
      readChar('-');
      int firstDigit = current;
      if (!readDigit()) {
        throw expected("digit");
      }
      if (firstDigit != '0') {
        while (readDigit()) {
        }
      }
      readFraction();
      readExponent();
      handler.endNumber(endCapture());
    }

    private boolean readFraction() throws IOException {
      if (!readChar('.')) {
        return false;
      }
      if (!readDigit()) {
        throw expected("digit");
      }
      while (readDigit()) {
      }
      return true;
    }

    private boolean readExponent() throws IOException {
      if (!readChar('e') && !readChar('E')) {
        return false;
      }
      if (!readChar('+')) {
        readChar('-');
      }
      if (!readDigit()) {
        throw expected("digit");
      }
      while (readDigit()) {
      }
      return true;
    }

    private boolean readChar(char ch) throws IOException {
      if (current != ch) {
        return false;
      }
      read();
      return true;
    }

    private boolean readDigit() throws IOException {
      if (!isDigit()) {
        return false;
      }
      read();
      return true;
    }

    private void skipWhiteSpace() throws IOException {
      while (isWhiteSpace()) {
        read();
      }
    }

    private void read() throws IOException {
      if (index == fill) {
        if (captureStart != -1) {
          captureBuffer.append(buffer, captureStart, fill - captureStart);
          captureStart = 0;
        }
        bufferOffset += fill;
        fill = reader.read(buffer, 0, buffer.length);
        index = 0;
        if (fill == -1) {
          current = -1;
          index++;
          return;
        }
      }
      if (current == '\n') {
        line++;
        lineOffset = bufferOffset + index;
      }
      current = buffer[index++];
    }

    private void startCapture() {
      if (captureBuffer == null) {
        captureBuffer = new StringBuilder();
      }
      captureStart = index - 1;
    }

    private void pauseCapture() {
      int end = current == -1 ? index : index - 1;
      captureBuffer.append(buffer, captureStart, end - captureStart);
      captureStart = -1;
    }

    private String endCapture() {
      int start = captureStart;
      int end = index - 1;
      captureStart = -1;
      if (captureBuffer.length() > 0) {
        captureBuffer.append(buffer, start, end - start);
        String captured = captureBuffer.toString();
        captureBuffer.setLength(0);
        return captured;
      }
      return new String(buffer, start, end - start);
    }

    Location getLocation() {
      int offset = bufferOffset + index - 1;
      int column = offset - lineOffset + 1;
      return new Location(offset, line, column);
    }

    private ParseException expected(String expected) {
      if (isEndOfText()) {
        return error("Unexpected end of input");
      }
      return error("Expected " + expected);
    }

    private ParseException error(String message) {
      return new ParseException(message, getLocation());
    }

    private boolean isWhiteSpace() {
      return current == ' ' || current == '\t' || current == '\n' || current == '\r';
    }

    private boolean isDigit() {
      return current >= '0' && current <= '9';
    }

    private boolean isHexDigit() {
      return current >= '0' && current <= '9'
          || current >= 'a' && current <= 'f'
          || current >= 'A' && current <= 'F';
    }

    private boolean isEndOfText() {
      return current == -1;
    }
  }

  @SuppressWarnings("serial") // use default serial UID
  static class JsonString extends JsonValue {

    private final String string;

    JsonString(String string) {
      if (string == null) {
        throw new NullPointerException("string is null");
      }
      this.string = string;
    }

    @Override
    void write(JsonWriter writer) throws IOException {
      writer.writeString(string);
    }

    @Override
    public boolean isString() {
      return true;
    }

    @Override
    public String asString() {
      return string;
    }

    @Override
    public int hashCode() {
      return string.hashCode();
    }

    @Override
    public boolean equals(Object object) {
      if (this == object) {
        return true;
      }
      if (object == null) {
        return false;
      }
      if (getClass() != object.getClass()) {
        return false;
      }
      JsonString other = (JsonString) object;
      return string.equals(other.string);
    }
  }

  /**
   * Represents a JSON value. This can be a JSON <strong>object</strong>, an <strong> array</strong>,
   * a <strong>number</strong>, a <strong>string</strong>, or one of the literals
   * <strong>true</strong>, <strong>false</strong>, and <strong>null</strong>.
   * <p>
   * The literals <strong>true</strong>, <strong>false</strong>, and <strong>null</strong> are
   * represented by the constants {@link #TRUE}, {@link #FALSE}, and {@link #NULL}.
   * </p>
   * <p>
   * JSON <strong>objects</strong> and <strong>arrays</strong> are represented by the subtypes
   * {@link JsonObject} and {@link JsonArray}. Instances of these types can be created using the
   * public constructors of these classes.
   * </p>
   * <p>
   * Instances that represent JSON <strong>numbers</strong>, <strong>strings</strong> and
   * <strong>boolean</strong> values can be created using the static factory methods
   * {@link #valueOf(String)}, {@link #valueOf(long)}, {@link #valueOf(double)}, etc.
   * </p>
   * <p>
   * In order to find out whether an instance of this class is of a certain type, the methods
   * {@link #isObject()}, {@link #isArray()}, {@link #isString()}, {@link #isNumber()} etc. can be
   * used.
   * </p>
   * <p>
   * If the type of a JSON value is known, the methods {@link #asObject()}, {@link #asArray()},
   * {@link #asString()}, {@link #asInt()}, etc. can be used to get this value directly in the
   * appropriate target type.
   * </p>
   * <p>
   * This class is <strong>not supposed to be extended</strong> by clients.
   * </p>
   */
  @SuppressWarnings("serial") // use default serial UID
  public static abstract class JsonValue implements Serializable {

    /**
     * Represents the JSON literal <code>true</code>.
     *
     * @deprecated Use <code>Json.TRUE</code> instead
     */
    @Deprecated
    public static final JsonValue TRUE = new JsonLiteral("true");

    /**
     * Represents the JSON literal <code>false</code>.
     *
     * @deprecated Use <code>Json.FALSE</code> instead
     */
    @Deprecated
    public static final JsonValue FALSE = new JsonLiteral("false");

    /**
     * Represents the JSON literal <code>null</code>.
     *
     * @deprecated Use <code>Json.NULL</code> instead
     */
    @Deprecated
    public static final JsonValue NULL = new JsonLiteral("null");

    JsonValue() {
      // prevent subclasses outside of this package
    }

    /**
     * Reads a JSON value from the given reader.
     * <p>
     * Characters are read in chunks and buffered internally, therefore wrapping an existing reader in
     * an additional <code>BufferedReader</code> does <strong>not</strong> improve reading
     * performance.
     * </p>
     *
     * @param reader the reader to read the JSON value from
     * @return the JSON value that has been read
     * @throws IOException    if an I/O error occurs in the reader
     * @throws ParseException if the input is not valid JSON
     * @deprecated Use {@link Json#parse(Reader)} instead
     */
    @Deprecated
    public static JsonValue readFrom(Reader reader) throws IOException {
      return Json.parse(reader);
    }

    /**
     * Reads a JSON value from the given string.
     *
     * @param text the string that contains the JSON value
     * @return the JSON value that has been read
     * @throws ParseException if the input is not valid JSON
     * @deprecated Use {@link Json#parse(String)} instead
     */
    @Deprecated
    public static JsonValue readFrom(String text) {
      return Json.parse(text);
    }

    /**
     * Returns a JsonValue instance that represents the given <code>int</code> value.
     *
     * @param value the value to get a JSON representation for
     * @return a JSON value that represents the given value
     * @deprecated Use <code>Json.value()</code> instead
     */
    @Deprecated
    public static JsonValue valueOf(int value) {
      return Json.value(value);
    }

    /**
     * Returns a JsonValue instance that represents the given <code>long</code> value.
     *
     * @param value the value to get a JSON representation for
     * @return a JSON value that represents the given value
     * @deprecated Use <code>Json.value()</code> instead
     */
    @Deprecated
    public static JsonValue valueOf(long value) {
      return Json.value(value);
    }

    /**
     * Returns a JsonValue instance that represents the given <code>float</code> value.
     *
     * @param value the value to get a JSON representation for
     * @return a JSON value that represents the given value
     * @deprecated Use <code>Json.value()</code> instead
     */
    @Deprecated
    public static JsonValue valueOf(float value) {
      return Json.value(value);
    }

    /**
     * Returns a JsonValue instance that represents the given <code>double</code> value.
     *
     * @param value the value to get a JSON representation for
     * @return a JSON value that represents the given value
     * @deprecated Use <code>Json.value()</code> instead
     */
    @Deprecated
    public static JsonValue valueOf(double value) {
      return Json.value(value);
    }

    /**
     * Returns a JsonValue instance that represents the given string.
     *
     * @param string the string to get a JSON representation for
     * @return a JSON value that represents the given string
     * @deprecated Use <code>Json.value()</code> instead
     */
    @Deprecated
    public static JsonValue valueOf(String string) {
      return Json.value(string);
    }

    /**
     * Returns a JsonValue instance that represents the given <code>boolean</code> value.
     *
     * @param value the value to get a JSON representation for
     * @return a JSON value that represents the given value
     * @deprecated Use <code>Json.value()</code> instead
     */
    @Deprecated
    public static JsonValue valueOf(boolean value) {
      return Json.value(value);
    }

    /**
     * Detects whether this value represents a JSON object. If this is the case, this value is an
     * instance of {@link JsonObject}.
     *
     * @return <code>true</code> if this value is an instance of JsonObject
     */
    public boolean isObject() {
      return false;
    }

    /**
     * Detects whether this value represents a JSON array. If this is the case, this value is an
     * instance of {@link JsonArray}.
     *
     * @return <code>true</code> if this value is an instance of JsonArray
     */
    public boolean isArray() {
      return false;
    }

    /**
     * Detects whether this value represents a JSON number.
     *
     * @return <code>true</code> if this value represents a JSON number
     */
    public boolean isNumber() {
      return false;
    }

    /**
     * Detects whether this value represents a JSON string.
     *
     * @return <code>true</code> if this value represents a JSON string
     */
    public boolean isString() {
      return false;
    }

    /**
     * Detects whether this value represents a boolean value.
     *
     * @return <code>true</code> if this value represents either the JSON literal <code>true</code> or
     * <code>false</code>
     */
    public boolean isBoolean() {
      return false;
    }

    /**
     * Detects whether this value represents the JSON literal <code>true</code>.
     *
     * @return <code>true</code> if this value represents the JSON literal <code>true</code>
     */
    public boolean isTrue() {
      return false;
    }

    /**
     * Detects whether this value represents the JSON literal <code>false</code>.
     *
     * @return <code>true</code> if this value represents the JSON literal <code>false</code>
     */
    public boolean isFalse() {
      return false;
    }

    /**
     * Detects whether this value represents the JSON literal <code>null</code>.
     *
     * @return <code>true</code> if this value represents the JSON literal <code>null</code>
     */
    public boolean isNull() {
      return false;
    }

    /**
     * Returns this JSON value as {@link JsonObject}, assuming that this value represents a JSON
     * object. If this is not the case, an exception is thrown.
     *
     * @return a JSONObject for this value
     * @throws UnsupportedOperationException if this value is not a JSON object
     */
    public JsonObject asObject() {
      throw new UnsupportedOperationException("Not an object: " + toString());
    }

    /**
     * Returns this JSON value as {@link JsonArray}, assuming that this value represents a JSON array.
     * If this is not the case, an exception is thrown.
     *
     * @return a JSONArray for this value
     * @throws UnsupportedOperationException if this value is not a JSON array
     */
    public JsonArray asArray() {
      throw new UnsupportedOperationException("Not an array: " + toString());
    }

    /**
     * Returns this JSON value as an <code>int</code> value, assuming that this value represents a
     * JSON number that can be interpreted as Java <code>int</code>. If this is not the case, an
     * exception is thrown.
     * <p>
     * To be interpreted as Java <code>int</code>, the JSON number must neither contain an exponent
     * nor a fraction part. Moreover, the number must be in the <code>Integer</code> range.
     * </p>
     *
     * @return this value as <code>int</code>
     * @throws UnsupportedOperationException if this value is not a JSON number
     * @throws NumberFormatException         if this JSON number can not be interpreted as <code>int</code> value
     */
    public int asInt() {
      throw new UnsupportedOperationException("Not a number: " + toString());
    }

    /**
     * Returns this JSON value as a <code>long</code> value, assuming that this value represents a
     * JSON number that can be interpreted as Java <code>long</code>. If this is not the case, an
     * exception is thrown.
     * <p>
     * To be interpreted as Java <code>long</code>, the JSON number must neither contain an exponent
     * nor a fraction part. Moreover, the number must be in the <code>Long</code> range.
     * </p>
     *
     * @return this value as <code>long</code>
     * @throws UnsupportedOperationException if this value is not a JSON number
     * @throws NumberFormatException         if this JSON number can not be interpreted as <code>long</code> value
     */
    public long asLong() {
      throw new UnsupportedOperationException("Not a number: " + toString());
    }

    /**
     * Returns this JSON value as a <code>float</code> value, assuming that this value represents a
     * JSON number. If this is not the case, an exception is thrown.
     * <p>
     * If the JSON number is out of the <code>Float</code> range, {@link Float#POSITIVE_INFINITY} or
     * {@link Float#NEGATIVE_INFINITY} is returned.
     * </p>
     *
     * @return this value as <code>float</code>
     * @throws UnsupportedOperationException if this value is not a JSON number
     */
    public float asFloat() {
      throw new UnsupportedOperationException("Not a number: " + toString());
    }

    /**
     * Returns this JSON value as a <code>double</code> value, assuming that this value represents a
     * JSON number. If this is not the case, an exception is thrown.
     * <p>
     * If the JSON number is out of the <code>Double</code> range, {@link Double#POSITIVE_INFINITY} or
     * {@link Double#NEGATIVE_INFINITY} is returned.
     * </p>
     *
     * @return this value as <code>double</code>
     * @throws UnsupportedOperationException if this value is not a JSON number
     */
    public double asDouble() {
      throw new UnsupportedOperationException("Not a number: " + toString());
    }

    /**
     * Returns this JSON value as String, assuming that this value represents a JSON string. If this
     * is not the case, an exception is thrown.
     *
     * @return the string represented by this value
     * @throws UnsupportedOperationException if this value is not a JSON string
     */
    public String asString() {
      throw new UnsupportedOperationException("Not a string: " + toString());
    }

    /**
     * Returns this JSON value as a <code>boolean</code> value, assuming that this value is either
     * <code>true</code> or <code>false</code>. If this is not the case, an exception is thrown.
     *
     * @return this value as <code>boolean</code>
     * @throws UnsupportedOperationException if this value is neither <code>true</code> or <code>false</code>
     */
    public boolean asBoolean() {
      throw new UnsupportedOperationException("Not a boolean: " + toString());
    }

    /**
     * Writes the JSON representation of this value to the given writer in its minimal form, without
     * any additional whitespace.
     * <p>
     * Writing performance can be improved by using a {@link java.io.BufferedWriter BufferedWriter}.
     * </p>
     *
     * @param writer the writer to write this value to
     * @throws IOException if an I/O error occurs in the writer
     */
    public void writeTo(Writer writer) throws IOException {
      writeTo(writer, WriterConfig.MINIMAL);
    }

    /**
     * Writes the JSON representation of this value to the given writer using the given formatting.
     * <p>
     * Writing performance can be improved by using a {@link java.io.BufferedWriter BufferedWriter}.
     * </p>
     *
     * @param writer the writer to write this value to
     * @param config a configuration that controls the formatting or <code>null</code> for the minimal form
     * @throws IOException if an I/O error occurs in the writer
     */
    public void writeTo(Writer writer, WriterConfig config) throws IOException {
      if (writer == null) {
        throw new NullPointerException("writer is null");
      }
      if (config == null) {
        throw new NullPointerException("config is null");
      }
      WritingBuffer buffer = new WritingBuffer(writer, 128);
      write(config.createWriter(buffer));
      buffer.flush();
    }

    /**
     * Returns the JSON string for this value in its minimal form, without any additional whitespace.
     * The result is guaranteed to be a valid input for the method {@link #readFrom(String)} and to
     * create a value that is <em>equal</em> to this object.
     *
     * @return a JSON string that represents this value
     */
    @Override
    public String toString() {
      return toString(WriterConfig.MINIMAL);
    }

    /**
     * Returns the JSON string for this value using the given formatting.
     *
     * @param config a configuration that controls the formatting or <code>null</code> for the minimal form
     * @return a JSON string that represents this value
     */
    public String toString(WriterConfig config) {
      StringWriter writer = new StringWriter();
      try {
        writeTo(writer, config);
      } catch (IOException exception) {
        // StringWriter does not throw IOExceptions
        throw new RuntimeException(exception);
      }
      return writer.toString();
    }

    /**
     * Indicates whether some other object is "equal to" this one according to the contract specified
     * in {@link Object#equals(Object)}.
     * <p>
     * Two JsonValues are considered equal if and only if they represent the same JSON text. As a
     * consequence, two given JsonObjects may be different even though they contain the same set of
     * names with the same values, but in a different order.
     * </p>
     *
     * @param object the reference object with which to compare
     * @return true if this object is the same as the object argument; false otherwise
     */
    @Override
    public boolean equals(Object object) {
      return super.equals(object);
    }

    @Override
    public int hashCode() {
      return super.hashCode();
    }

    abstract void write(JsonWriter writer) throws IOException;
  }

  static class JsonWriter {

    private static final int CONTROL_CHARACTERS_END = 0x001f;

    private static final char[] QUOT_CHARS = {'\\', '"'};
    private static final char[] BS_CHARS = {'\\', '\\'};
    private static final char[] LF_CHARS = {'\\', 'n'};
    private static final char[] CR_CHARS = {'\\', 'r'};
    private static final char[] TAB_CHARS = {'\\', 't'};
    // In JavaScript, U+2028 and U+2029 characters count as line endings and must be encoded.
    // http://stackoverflow.com/questions/2965293/javascript-parse-error-on-u2028-unicode-character
    private static final char[] UNICODE_2028_CHARS = {'\\', 'u', '2', '0', '2', '8'};
    private static final char[] UNICODE_2029_CHARS = {'\\', 'u', '2', '0', '2', '9'};
    private static final char[] HEX_DIGITS = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
        'a', 'b', 'c', 'd', 'e', 'f'};

    protected final Writer writer;

    JsonWriter(Writer writer) {
      this.writer = writer;
    }

    protected void writeLiteral(String value) throws IOException {
      writer.write(value);
    }

    protected void writeNumber(String string) throws IOException {
      writer.write(string);
    }

    protected void writeString(String string) throws IOException {
      writer.write('"');
      writeJsonString(string);
      writer.write('"');
    }

    protected void writeArrayOpen() throws IOException {
      writer.write('[');
    }

    protected void writeArrayClose() throws IOException {
      writer.write(']');
    }

    protected void writeArraySeparator() throws IOException {
      writer.write(',');
    }

    protected void writeObjectOpen() throws IOException {
      writer.write('{');
    }

    protected void writeObjectClose() throws IOException {
      writer.write('}');
    }

    protected void writeMemberName(String name) throws IOException {
      writer.write('"');
      writeJsonString(name);
      writer.write('"');
    }

    protected void writeMemberSeparator() throws IOException {
      writer.write(':');
    }

    protected void writeObjectSeparator() throws IOException {
      writer.write(',');
    }

    protected void writeJsonString(String string) throws IOException {
      int length = string.length();
      int start = 0;
      for (int index = 0; index < length; index++) {
        char[] replacement = getReplacementChars(string.charAt(index));
        if (replacement != null) {
          writer.write(string, start, index - start);
          writer.write(replacement);
          start = index + 1;
        }
      }
      writer.write(string, start, length - start);
    }

    private static char[] getReplacementChars(char ch) {
      if (ch > '\\') {
        if (ch < '\u2028' || ch > '\u2029') {
          // The lower range contains 'a' .. 'z'. Only 2 checks required.
          return null;
        }
        return ch == '\u2028' ? UNICODE_2028_CHARS : UNICODE_2029_CHARS;
      }
      if (ch == '\\') {
        return BS_CHARS;
      }
      if (ch > '"') {
        // This range contains '0' .. '9' and 'A' .. 'Z'. Need 3 checks to get here.
        return null;
      }
      if (ch == '"') {
        return QUOT_CHARS;
      }
      if (ch > CONTROL_CHARACTERS_END) {
        return null;
      }
      if (ch == '\n') {
        return LF_CHARS;
      }
      if (ch == '\r') {
        return CR_CHARS;
      }
      if (ch == '\t') {
        return TAB_CHARS;
      }
      return new char[]{'\\', 'u', '0', '0', HEX_DIGITS[ch >> 4 & 0x000f], HEX_DIGITS[ch & 0x000f]};
    }
  }

  /**
   * An immutable object that represents a location in the parsed text.
   */
  public static class Location {

    /**
     * The absolute character index, starting at 0.
     */
    public final int offset;

    /**
     * The line number, starting at 1.
     */
    public final int line;

    /**
     * The column number, starting at 1.
     */
    public final int column;

    Location(int offset, int line, int column) {
      this.offset = offset;
      this.column = column;
      this.line = line;
    }

    @Override
    public String toString() {
      return line + ":" + column;
    }

    @Override
    public int hashCode() {
      return offset;
    }

    @Override
    public boolean equals(Object obj) {
      if (this == obj) {
        return true;
      }
      if (obj == null) {
        return false;
      }
      if (getClass() != obj.getClass()) {
        return false;
      }
      Location other = (Location) obj;
      return offset == other.offset && column == other.column && line == other.line;
    }
  }

  /**
   * An unchecked exception to indicate that an input does not qualify as valid JSON.
   */
  @SuppressWarnings("serial") // use default serial UID
  public static class ParseException extends RuntimeException {

    private final Location location;

    ParseException(String message, Location location) {
      super(message + " at " + location);
      this.location = location;
    }

    /**
     * Returns the location at which the error occurred.
     *
     * @return the error location
     */
    public Location getLocation() {
      return location;
    }

    /**
     * Returns the absolute character index at which the error occurred. The offset of the first
     * character of a document is 0.
     *
     * @return the character offset at which the error occurred, will be &gt;= 0
     * @deprecated Use {@link #getLocation()} instead
     */
    @Deprecated
    public int getOffset() {
      return location.offset;
    }

    /**
     * Returns the line number in which the error occurred. The number of the first line is 1.
     *
     * @return the line in which the error occurred, will be &gt;= 1
     * @deprecated Use {@link #getLocation()} instead
     */
    @Deprecated
    public int getLine() {
      return location.line;
    }

    /**
     * Returns the column number at which the error occurred, i.e. the number of the character in its
     * line. The number of the first character of a line is 1.
     *
     * @return the column in which the error occurred, will be &gt;= 1
     * @deprecated Use {@link #getLocation()} instead
     */
    @Deprecated
    public int getColumn() {
      return location.column;
    }
  }

  /**
   * Enables human readable JSON output by inserting whitespace between values.after commas and
   * colons. Example:
   * <p>
   * <pre>
   * jsonValue.writeTo(writer, PrettyPrint.singleLine());
   * </pre>
   */
  public static class PrettyPrint extends WriterConfig {

    private final char[] indentChars;

    protected PrettyPrint(char[] indentChars) {
      this.indentChars = indentChars;
    }

    /**
     * Print every value on a separate line. Use tabs (<code>\t</code>) for indentation.
     *
     * @return A PrettyPrint instance for wrapped mode with tab indentation
     */
    public static PrettyPrint singleLine() {
      return new PrettyPrint(null);
    }

    /**
     * Print every value on a separate line. Use the given number of spaces for indentation.
     *
     * @param number the number of spaces to use
     * @return A PrettyPrint instance for wrapped mode with spaces indentation
     */
    public static PrettyPrint indentWithSpaces(int number) {
      if (number < 0) {
        throw new IllegalArgumentException("number is negative");
      }
      char[] chars = new char[number];
      Arrays.fill(chars, ' ');
      return new PrettyPrint(chars);
    }

    /**
     * Do not break lines, but still insert whitespace between values.
     *
     * @return A PrettyPrint instance for single-line mode
     */
    public static PrettyPrint indentWithTabs() {
      return new PrettyPrint(new char[]{'\t'});
    }

    @Override
    protected JsonWriter createWriter(Writer writer) {
      return new PrettyPrintWriter(writer, indentChars);
    }

    private static class PrettyPrintWriter extends JsonWriter {

      private final char[] indentChars;
      private int indent;

      private PrettyPrintWriter(Writer writer, char[] indentChars) {
        super(writer);
        this.indentChars = indentChars;
      }

      @Override
      protected void writeArrayOpen() throws IOException {
        indent++;
        writer.write('[');
        writeNewLine();
      }

      @Override
      protected void writeArrayClose() throws IOException {
        indent--;
        writeNewLine();
        writer.write(']');
      }

      @Override
      protected void writeArraySeparator() throws IOException {
        writer.write(',');
        if (!writeNewLine()) {
          writer.write(' ');
        }
      }

      @Override
      protected void writeObjectOpen() throws IOException {
        indent++;
        writer.write('{');
        writeNewLine();
      }

      @Override
      protected void writeObjectClose() throws IOException {
        indent--;
        writeNewLine();
        writer.write('}');
      }

      @Override
      protected void writeMemberSeparator() throws IOException {
        writer.write(':');
        writer.write(' ');
      }

      @Override
      protected void writeObjectSeparator() throws IOException {
        writer.write(',');
        if (!writeNewLine()) {
          writer.write(' ');
        }
      }

      private boolean writeNewLine() throws IOException {
        if (indentChars == null) {
          return false;
        }
        writer.write('\n');
        for (int i = 0; i < indent; i++) {
          writer.write(indentChars);
        }
        return true;
      }
    }
  }


  /**
   * Controls the formatting of the JSON output. Use one of the available constants.
   */
  public static abstract class WriterConfig {

    /**
     * Write JSON in its minimal form, without any additional whitespace. This is the default.
     */
    public static WriterConfig MINIMAL = new WriterConfig() {
      @Override
      JsonWriter createWriter(Writer writer) {
        return new JsonWriter(writer);
      }
    };

    /**
     * Write JSON in pretty-print, with each value on a separate line and an indentation of two
     * spaces.
     */
    public static WriterConfig PRETTY_PRINT = PrettyPrint.indentWithSpaces(2);

    abstract JsonWriter createWriter(Writer writer);
  }

  /**
   * A lightweight writing buffer to reduce the amount of write operations to be performed on the
   * underlying writer. This implementation is not thread-safe. It deliberately deviates from the
   * contract of Writer. In particular, it does not flush or close the wrapped writer nor does it
   * ensure that the wrapped writer is open.
   */
  static class WritingBuffer extends Writer {

    private final Writer writer;
    private final char[] buffer;
    private int fill = 0;

    WritingBuffer(Writer writer) {
      this(writer, 16);
    }

    WritingBuffer(Writer writer, int bufferSize) {
      this.writer = writer;
      buffer = new char[bufferSize];
    }

    @Override
    public void write(int c) throws IOException {
      if (fill > buffer.length - 1) {
        flush();
      }
      buffer[fill++] = (char) c;
    }

    @Override
    public void write(char[] cbuf, int off, int len) throws IOException {
      if (fill > buffer.length - len) {
        flush();
        if (len > buffer.length) {
          writer.write(cbuf, off, len);
          return;
        }
      }
      System.arraycopy(cbuf, off, buffer, fill, len);
      fill += len;
    }

    @Override
    public void write(String str, int off, int len) throws IOException {
      if (fill > buffer.length - len) {
        flush();
        if (len > buffer.length) {
          writer.write(str, off, len);
          return;
        }
      }
      str.getChars(off, off + len, buffer, fill);
      fill += len;
    }

    /**
     * Flushes the internal buffer but does not flush the wrapped writer.
     */
    @Override
    public void flush() throws IOException {
      writer.write(buffer, 0, fill);
      fill = 0;
    }

    /**
     * Does not close or flush the wrapped writer.
     */
    @Override
    public void close() throws IOException {
    }
  }
}

<|start_filename|>src/main/java/com/lokalized/LoggerType.java<|end_filename|>
/*
 * Copyright 2017 Product Mog LLC.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.lokalized;

import javax.annotation.Nonnull;

import static java.util.Objects.requireNonNull;

/**
 * @author <a href="https://revetkn.com"><NAME></a>
 */
enum LoggerType {
  ROOT("com.lokalized"),
  STRINGS("com.lokalized.STRINGS"),
  LOCALIZED_STRING_LOADER("com.lokalized.LOCALIZED_STRING_LOADER"),
  TEST("com.lokalized.TEST");

  @Nonnull
  private final String loggerName;

  LoggerType(@Nonnull String loggerName) {
    requireNonNull(loggerName);
    this.loggerName = loggerName;
  }

  @Nonnull
  public String getLoggerName() {
    return loggerName;
  }
}


<|start_filename|>src/main/java/com/lokalized/LoggingUtils.java<|end_filename|>
/*
 * Copyright 2017 Product Mog LLC.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.lokalized;

import javax.annotation.Nonnull;
import javax.annotation.concurrent.ThreadSafe;
import java.util.Objects;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * Collection of utility methods for working with logging.
 * <p>
 * This is for internal testing and debugging only!
 *
 * @author <a href="https://revetkn.com"><NAME></a>
 */
@ThreadSafe
final class LoggingUtils {
  private LoggingUtils() {
    // Non-instantiable
  }

  /**
   * Overrides the system's root logger level.
   * <p>
   * This is for internal testing and debugging only!
   *
   * @param level the level to use, not null
   */
  @Nonnull
  public static void setRootLoggerLevel(@Nonnull Level level) {
    Objects.requireNonNull(level);

    Logger rootLogger = Logger.getLogger("");

    for (Handler handler : rootLogger.getHandlers())
      handler.setLevel(Level.FINEST);

    rootLogger.setLevel(level);
  }
}


<|start_filename|>src/main/java/com/lokalized/LocalizedStringUtils.java<|end_filename|>
/*
 * Copyright 2017 Transmogrify LLC.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.lokalized;

import javax.annotation.Nonnull;

import static java.lang.String.format;
import static java.util.Objects.requireNonNull;

/**
 * Collection of utility methods for working with localized strings.
 * <p>
 * This is for internal use only!
 *
 * @author <a href="https://revetkn.com"><NAME></a>
 */
final class LocalizedStringUtils {
  @Nonnull
  private static final String CARDINALITY_NAME_PREFIX;
  @Nonnull
  private static final String ORDINALITY_NAME_PREFIX;

  static {
    CARDINALITY_NAME_PREFIX = "CARDINALITY_";
    ORDINALITY_NAME_PREFIX = "ORDINALITY_";
  }

  private LocalizedStringUtils() {
    // Non-instantiable
  }

  /**
   * Massages Cardinality name ({@code ONE}) to match localized strings file format {@code "CARDINALITY_ONE"}.
   *
   * @param cardinalityName the cardinality name to massage, not null
   * @return the localized strings file representation of a cardinality name, not null
   */
  @Nonnull
  static String localizedStringNameForCardinalityName(@Nonnull String cardinalityName) {
    requireNonNull(cardinalityName);
    return format("%s%s", CARDINALITY_NAME_PREFIX, cardinalityName);
  }

  /**
   * Massages localized strings file format {@code "CARDINALITY_ONE"} to match cardinality name ({@code ONE}).
   *
   * @param localizedStringName the localized strings cardinality name to massage, not null
   * @return the cardinality name of the localized strings file representation, not null
   * @throws IllegalArgumentException if the localized strings name is malformed
   */
  @Nonnull
  static String cardinalityNameForLocalizedStringName(@Nonnull String localizedStringName) {
    requireNonNull(localizedStringName);

    if (!localizedStringName.startsWith(CARDINALITY_NAME_PREFIX))
      throw new IllegalArgumentException(format("Cardinality value '%s' does not start with prefix '%s'",
          localizedStringName, CARDINALITY_NAME_PREFIX));

    return localizedStringName.substring(CARDINALITY_NAME_PREFIX.length());
  }

  /**
   * Massages Ordinality name ({@code ONE}) to match localized strings file format {@code "ORDINALITY_ONE"}.
   *
   * @param ordinalityName the ordinality name to massage, not null
   * @return the localized strings file representation of an ordinality name, not null
   */
  @Nonnull
  static String localizedStringNameForOrdinalityName(@Nonnull String ordinalityName) {
    requireNonNull(ordinalityName);
    return format("%s%s", ORDINALITY_NAME_PREFIX, ordinalityName);
  }

  /**
   * Massages localized strings file format {@code "ORDINALITY_ONE"} to match ordinality name ({@code ONE}).
   *
   * @param localizedStringName the localized strings ordinality name to massage, not null
   * @return the ordinality name of the localized strings file representation, not null
   * @throws IllegalArgumentException if the localized strings name is malformed
   */
  @Nonnull
  static String ordinalityNameForLocalizedStringName(@Nonnull String localizedStringName) {
    requireNonNull(localizedStringName);

    if (!localizedStringName.startsWith(ORDINALITY_NAME_PREFIX))
      throw new IllegalArgumentException(format("Ordinality value '%s' does not start with prefix '%s'",
          localizedStringName, ORDINALITY_NAME_PREFIX));

    return localizedStringName.substring(ORDINALITY_NAME_PREFIX.length());
  }
}

<|start_filename|>src/main/java/com/lokalized/DefaultStrings.java<|end_filename|>
/*
 * Copyright 2017 Product Mog LLC.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.lokalized;

import com.lokalized.LocalizedString.LanguageFormTranslation;
import com.lokalized.LocalizedString.LanguageFormTranslationRange;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.annotation.concurrent.Immutable;
import javax.annotation.concurrent.NotThreadSafe;
import javax.annotation.concurrent.ThreadSafe;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Locale.LanguageRange;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Supplier;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;

import static java.lang.String.format;
import static java.util.Objects.requireNonNull;

/**
 * Default implementation of a localized string provider.
 * <p>
 * It is recommended to use a single instance of this class across your entire application.
 * <p>
 * In multi-tenant systems like a web application where each user might have a different locale,
 * your {@code localeSupplier} might return the locale specified by current request.
 *
 * @author <a href="https://<EMAIL>"><NAME></a>
 */
@ThreadSafe
public class DefaultStrings implements Strings {
  @Nonnull
  private final String fallbackLanguageCode;
  @Nonnull
  private final Map<Locale, Set<LocalizedString>> localizedStringsByLocale;
  @Nullable
  private final Supplier<Locale> localeSupplier;
  @Nullable
  private final Supplier<List<LanguageRange>> languageRangesSupplier;
  @Nonnull
  private final FailureMode failureMode;
  @Nonnull
  private final Locale fallbackLocale;
  @Nonnull
  private final StringInterpolator stringInterpolator;
  @Nonnull
  private final ExpressionEvaluator expressionEvaluator;
  @Nonnull
  private final Logger logger;

  /**
   * Cache of localized strings by key by locale.
   * <p>
   * This is our "master" reference localized string storage that other data structures will point to.
   */
  @Nonnull
  private final Map<Locale, Map<String, LocalizedString>> localizedStringsByKeyByLocale;

  /**
   * Cache of best-matching strings for the given locale (populated on-demand per request at runtime).
   * <p>
   * List elements are ordered by most to least specific, e.g. if your locale is {@code en-US}, the first list element
   * might be {@code en-US} strings and the second would be {@code en} strings.
   * <p>
   * There will always be at least one element in the list - the fallback locale.
   */
  @Nonnull
  private final ConcurrentHashMap<Locale, List<LocalizedStringSource>> localizedStringSourcesByLocale;

  /**
   * Constructs a localized string provider with builder-supplied data.
   * <p>
   * The fallback language code must be an ISO 639 alpha-2 or alpha-3 language code.
   * When a language has both an alpha-2 code and an alpha-3 code, the alpha-2 code must be used.
   *
   * @param fallbackLanguageCode    fallback language code, not null
   * @param localizedStringSupplier supplier of localized strings, not null
   * @param localeSupplier          locale supplier, may be null
   * @param languageRangesSupplier  language ranges supplier, may be null
   * @param failureMode             strategy for dealing with lookup failures, may be null
   */
  protected DefaultStrings(@Nonnull String fallbackLanguageCode,
                           @Nonnull Supplier<Map<Locale, ? extends Iterable<LocalizedString>>> localizedStringSupplier,
                           @Nullable Supplier<Locale> localeSupplier,
                           @Nullable Supplier<List<LanguageRange>> languageRangesSupplier,
                           @Nullable FailureMode failureMode) {
    requireNonNull(fallbackLanguageCode);
    requireNonNull(localizedStringSupplier);

    this.logger = Logger.getLogger(LoggerType.STRINGS.getLoggerName());

    Map<Locale, ? extends Iterable<LocalizedString>> suppliedLocalizedStringsByLocale = localizedStringSupplier.get();

    if (suppliedLocalizedStringsByLocale == null)
      suppliedLocalizedStringsByLocale = Collections.emptyMap();

    // Defensive copy of iterator to unmodifiable set
    Map<Locale, Set<LocalizedString>> localizedStringsByLocale = suppliedLocalizedStringsByLocale.entrySet().stream()
        .collect(Collectors.toMap(
            entry -> entry.getKey(),
            entry -> {
              Set<LocalizedString> localizedStrings = new LinkedHashSet<>();
              entry.getValue().forEach(localizedStrings::add);
              return Collections.unmodifiableSet(localizedStrings);
            }
        ));

    this.fallbackLocale = Locale.forLanguageTag(fallbackLanguageCode);
    this.fallbackLanguageCode = fallbackLanguageCode;
    this.localizedStringsByLocale = Collections.unmodifiableMap(localizedStringsByLocale);
    this.languageRangesSupplier = languageRangesSupplier;
    this.failureMode = failureMode == null ? FailureMode.USE_FALLBACK : failureMode;
    this.stringInterpolator = new StringInterpolator();
    this.expressionEvaluator = new ExpressionEvaluator();

    this.localizedStringsByKeyByLocale = Collections.unmodifiableMap(localizedStringsByLocale.entrySet().stream()
        .collect(Collectors.toMap(
            entry1 -> entry1.getKey(),
            entry1 ->
                Collections.unmodifiableMap(entry1.getValue().stream()
                    .collect(Collectors.toMap(
                        entry2 -> entry2.getKey(),
                        entry2 -> entry2
                        )
                    )))));

    this.localizedStringSourcesByLocale = new ConcurrentHashMap<>();

    if (!localizedStringsByLocale.containsKey(getFallbackLocale()))
      throw new IllegalArgumentException(format("Specified fallback language code is '%s' but no matching " +
              "localized strings locale was found. Known locales: [%s]", fallbackLanguageCode,
          localizedStringsByLocale.keySet().stream()
              .map(locale -> locale.toLanguageTag())
              .sorted()
              .collect(Collectors.joining(", "))));

    if (localeSupplier != null && languageRangesSupplier != null)
      throw new IllegalArgumentException(format("You cannot provide both a localeSupplier " +
          "and a languageRangesSupplier when building an instance of %s - you must pick one of the two.", getClass().getSimpleName()));

    if (localeSupplier == null && languageRangesSupplier == null)
      this.localeSupplier = () -> getFallbackLocale();
    else
      this.localeSupplier = localeSupplier;
  }

  @Nonnull
  @Override
  public String get(@Nonnull String key) {
    requireNonNull(key);
    return get(key, null, null);
  }

  @Nonnull
  @Override
  public String get(@Nonnull String key, @Nullable Locale locale) {
    requireNonNull(key);
    return get(key, null, locale);
  }

  @Nonnull
  @Override
  public String get(@Nonnull String key, @Nullable Map<String, Object> placeholders) {
    requireNonNull(key);
    return get(key, placeholders, null);
  }

  @Nonnull
  @Override
  public String get(@Nonnull String key, @Nullable Map<String, Object> placeholders, @Nullable Locale locale) {
    requireNonNull(key);

    if (placeholders == null)
      placeholders = Collections.emptyMap();

    if (locale == null)
      locale = getImplicitLocale();

    String translation = null;
    Map<String, Object> mutableContext = new HashMap<>(placeholders);
    Map<String, Object> immutableContext = Collections.unmodifiableMap(placeholders);
    List<LocalizedStringSource> localizedStringSources = getLocalizedStringSourcesForLocale(locale);

    for (LocalizedStringSource localizedStringSource : localizedStringSources) {
      LocalizedString localizedString = localizedStringSource.getLocalizedStringsByKey().get(key);

      if (localizedString == null) {
        logger.finer(format("No match for '%s' was found in '%s'", key, localizedStringSource.getLocale().toLanguageTag()));
      } else {
        logger.finer(format("A match for '%s' was found in '%s'", key, localizedStringSource.getLocale().toLanguageTag()));
        translation = getInternal(key, localizedString, mutableContext, immutableContext, localizedStringSource.getLocale()).orElse(null);
        break;
      }
    }

    if (translation == null) {
      logger.finer(format("No match for '%s' was found in any strings file.", key));
      translation = stringInterpolator.interpolate(key, mutableContext);
    }

    return translation;
  }

  /**
   * Recursive method which attempts to translate a localized string.
   *
   * @param key              the toplevel translation key (always the same regardless of recursion depth), not null
   * @param localizedString  the localized string on which to operate, not null
   * @param mutableContext   the mutable context for the translation, not null
   * @param immutableContext the original user-supplied translation context, not null
   * @param locale           the locale to use for evaluation, not null
   * @return the translation, if possible (may not be possible if no translation value specified and no alternative expressions match), not null
   */
  @Nonnull
  protected Optional<String> getInternal(@Nonnull String key, @Nonnull LocalizedString localizedString,
                                         @Nonnull Map<String, Object> mutableContext, @Nonnull Map<String, Object> immutableContext,
                                         @Nonnull Locale locale) {
    requireNonNull(key);
    requireNonNull(localizedString);
    requireNonNull(mutableContext);
    requireNonNull(immutableContext);
    requireNonNull(locale);

    // First, see if any alternatives match by evaluating them
    for (LocalizedString alternative : localizedString.getAlternatives()) {
      if (getExpressionEvaluator().evaluate(alternative.getKey(), mutableContext, locale)) {
        logger.finer(format("An alternative match for '%s' was found for key '%s' and context %s", alternative.getKey(), key, mutableContext));

        // If we have a matching alternative, recurse into it
        return getInternal(key, alternative, mutableContext, immutableContext, locale);
      }
    }

    if (!localizedString.getTranslation().isPresent())
      return Optional.empty();

    String translation = localizedString.getTranslation().get();

    for (Entry<String, LanguageFormTranslation> entry : localizedString.getLanguageFormTranslationsByPlaceholder().entrySet()) {
      String placeholderName = entry.getKey();
      LanguageFormTranslation languageFormTranslation = entry.getValue();
      Object value = null;
      Object rangeStart = null;
      Object rangeEnd = null;
      Map<Cardinality, String> translationsByCardinality = new HashMap<>();
      Map<Ordinality, String> translationsByOrdinality = new HashMap<>();
      Map<Gender, String> translationsByGender = new HashMap<>();

      if (languageFormTranslation.getRange().isPresent()) {
        LanguageFormTranslationRange languageFormTranslationRange = languageFormTranslation.getRange().get();
        rangeStart = immutableContext.get(languageFormTranslationRange.getStart());
        rangeEnd = immutableContext.get(languageFormTranslationRange.getEnd());
      } else {
        value = immutableContext.get(languageFormTranslation.getValue().get());
      }

      for (Entry<LanguageForm, String> translationEntry : languageFormTranslation.getTranslationsByLanguageForm().entrySet()) {
        LanguageForm languageForm = translationEntry.getKey();
        String translatedLanguageForm = translationEntry.getValue();

        if (languageForm instanceof Cardinality)
          translationsByCardinality.put((Cardinality) languageForm, translatedLanguageForm);
        else if (languageForm instanceof Ordinality)
          translationsByOrdinality.put((Ordinality) languageForm, translatedLanguageForm);
        else if (languageForm instanceof Gender)
          translationsByGender.put((Gender) languageForm, translatedLanguageForm);
        else
          throw new IllegalArgumentException(format("Encountered unrecognized language form %s", languageForm));
      }

      int distinctLanguageForms = (translationsByCardinality.size() > 0 ? 1 : 0) +
          (translationsByOrdinality.size() > 0 ? 1 : 0) +
          (translationsByGender.size() > 0 ? 1 : 0);

      if (distinctLanguageForms > 1)
        throw new IllegalArgumentException(format("You cannot mix-and-match language forms. Offending localized string was %s", localizedString));

      if (distinctLanguageForms == 0)
        continue;

      // Handle plural cardinalities
      if (translationsByCardinality.size() > 0) {
        // Special case: calculate range from min and max if this is a range-driven cardinality
        if (languageFormTranslation.getRange().isPresent()) {
          if (rangeStart == null)
            rangeStart = 0;
          if (rangeEnd == null)
            rangeEnd = 0;

          if (!(rangeStart instanceof Number)) {
            logger.warning(format("Range start '%s' for '%s' is not a number, falling back to 0.",
                rangeStart, languageFormTranslation.getValue()));
            rangeStart = 0;
          }

          if (!(rangeEnd instanceof Number)) {
            logger.warning(format("Range value end '%s' for '%s' is not a number, falling back to 0.",
                rangeEnd, languageFormTranslation.getValue()));
            rangeEnd = 0;
          }

          Cardinality startCardinality = Cardinality.forNumber((Number) rangeStart, locale);
          Cardinality endCardinality = Cardinality.forNumber((Number) rangeEnd, locale);
          Cardinality rangeCardinality = Cardinality.forRange(startCardinality, endCardinality, locale);

          String cardinalityTranslation = translationsByCardinality.get(rangeCardinality);

          if (cardinalityTranslation == null)
            logger.warning(format("Unable to find %s translation for range cardinality %s (start was %s, end was %s). Localized string was %s",
                Cardinality.class.getSimpleName(), rangeCardinality.name(), startCardinality.name(), endCardinality.name(), localizedString));

          mutableContext.put(placeholderName, cardinalityTranslation);
        } else {
          // Normal "non-range" cardinality
          if (value == null)
            value = 0;

          if (!(value instanceof Number)) {
            logger.warning(format("Value '%s' for '%s' is not a number, falling back to 0.",
                value, languageFormTranslation.getValue()));
            value = 0;
          }

          Cardinality cardinality = Cardinality.forNumber((Number) value, locale);
          String cardinalityTranslation = translationsByCardinality.get(cardinality);

          if (cardinalityTranslation == null)
            logger.warning(format("Unable to find %s translation for %s. Localized string was %s",
                Cardinality.class.getSimpleName(), cardinality.name(), localizedString));

          mutableContext.put(placeholderName, cardinalityTranslation);
        }
      }

      // Handle plural ordinalities
      if (translationsByOrdinality.size() > 0) {
        if (value == null)
          value = 0;

        if (!(value instanceof Number)) {
          logger.warning(format("Value '%s' for '%s' is not a number, falling back to 0.",
              value, languageFormTranslation.getValue()));
          value = 0;
        }

        Ordinality ordinality = Ordinality.forNumber((Number) value, locale);
        String ordinalityTranslation = translationsByOrdinality.get(ordinality);

        if (ordinalityTranslation == null)
          logger.warning(format("Unable to find %s translation for %s. Localized string was %s",
              Ordinality.class.getSimpleName(), ordinality.name(), localizedString));

        mutableContext.put(placeholderName, ordinalityTranslation);
      }

      // Handle genders
      if (translationsByGender.size() > 0) {
        if (value == null) {
          logger.warning(format("Value '%s' for '%s' is null. No replacement will be performed.", value,
              languageFormTranslation.getValue()));
          continue;
        }

        if (!(value instanceof Gender)) {
          logger.warning(format("Value '%s' for '%s' is not a %s. No replacement will be performed.", value,
              languageFormTranslation.getValue(), Gender.class.getSimpleName()));
          continue;
        }

        Gender gender = (Gender) value;
        String genderTranslation = translationsByGender.get(gender);

        if (genderTranslation == null)
          logger.warning(format("Unable to find %s translation for %s. Localized string was %s",
              Gender.class.getSimpleName(), gender.name(), localizedString));

        mutableContext.put(placeholderName, genderTranslation);
      }
    }

    translation = stringInterpolator.interpolate(translation, mutableContext);

    return Optional.of(translation);
  }

  @Nonnull
  protected List<LocalizedStringSource> getLocalizedStringSourcesForLocale(@Nonnull Locale locale) {
    requireNonNull(locale);

    return getLocalizedStringSourcesByLocale().computeIfAbsent(locale, (ignored) -> {
      String language = LocaleUtils.normalizedLanguage(locale).orElse(null);
      String script = locale.getScript();
      String country = locale.getCountry();
      String variant = locale.getVariant();
      Set<Character> extensionKeys = locale.hasExtensions() ? locale.getExtensionKeys() : Collections.emptySet();
      Set<LocalizedString> localizedStrings;
      Set<Locale> matchingLocales = new HashSet<>(5);
      List<LocalizedStringSource> localizedStringSources = new ArrayList<>(5);

      if (logger.isLoggable(Level.FINER))
        logger.finer(format("Finding strings files that match locale '%s'...", locale.toLanguageTag()));

      // Try most specific (matches all 5 criteria) and move back to least specific
      Locale.Builder extensionsLocaleBuilder =
          new Locale.Builder().setLanguage(language).setScript(script).setRegion(country).setVariant(variant);

      for (Character extensionKey : extensionKeys)
        extensionsLocaleBuilder.setExtension(extensionKey, locale.getExtension(extensionKey));

      Locale extensionsLocale = extensionsLocaleBuilder.build();
      matchingLocales.add(extensionsLocale);
      localizedStrings = getLocalizedStringsByLocale().get(extensionsLocale);

      if (localizedStrings != null) {
        localizedStringSources.add(new LocalizedStringSource(extensionsLocale, getLocalizedStringsByKeyByLocale().get(extensionsLocale)));

        if (logger.isLoggable(Level.FINER))
          logger.finer(format("A matching strings file for locale '%s' is '%s'", locale.toLanguageTag(),
              extensionsLocale.toLanguageTag()));
      }

      // Variant (4)
      Locale variantLocale =
          new Locale.Builder().setLanguage(language).setScript(script).setRegion(country).setVariant(variant)
              .build();

      if (!matchingLocales.contains(variantLocale)) {
        matchingLocales.add(variantLocale);

        localizedStrings = getLocalizedStringsByLocale().get(variantLocale);

        if (localizedStrings != null) {
          localizedStringSources.add(new LocalizedStringSource(variantLocale, getLocalizedStringsByKeyByLocale().get(variantLocale)));

          if (logger.isLoggable(Level.FINER))
            logger.finer(format("A matching strings file for locale '%s' is '%s'", locale.toLanguageTag(),
                variantLocale.toLanguageTag()));
        }
      }

      // Region (3)
      Locale regionLocale = new Locale.Builder().setLanguage(language).setScript(script).setRegion(country).build();

      if (!matchingLocales.contains(regionLocale)) {
        matchingLocales.add(regionLocale);

        localizedStrings = getLocalizedStringsByLocale().get(regionLocale);

        if (localizedStrings != null) {
          localizedStringSources.add(new LocalizedStringSource(regionLocale, getLocalizedStringsByKeyByLocale().get(regionLocale)));

          if (logger.isLoggable(Level.FINER))
            logger.finer(format("A matching strings file for locale '%s' is '%s'", locale.toLanguageTag(),
                regionLocale.toLanguageTag()));
        }
      }

      // Script (2)
      Locale scriptLocale = new Locale.Builder().setLanguage(language).setScript(script).build();

      if (!matchingLocales.contains(scriptLocale)) {
        matchingLocales.add(scriptLocale);

        localizedStrings = getLocalizedStringsByLocale().get(scriptLocale);

        if (localizedStrings != null) {
          localizedStringSources.add(new LocalizedStringSource(scriptLocale, getLocalizedStringsByKeyByLocale().get(scriptLocale)));

          if (logger.isLoggable(Level.FINER))
            logger.finer(format("A matching strings file for locale '%s' is '%s'", locale.toLanguageTag(),
                scriptLocale.toLanguageTag()));
        }
      }

      // Language (1)
      Locale languageLocale = new Locale.Builder().setLanguage(language).build();

      if (!matchingLocales.contains(languageLocale)) {
        matchingLocales.add(languageLocale);

        localizedStrings = getLocalizedStringsByLocale().get(languageLocale);

        if (localizedStrings != null) {
          localizedStringSources.add(new LocalizedStringSource(languageLocale, getLocalizedStringsByKeyByLocale().get(languageLocale)));

          if (logger.isLoggable(Level.FINER))
            logger.finer(format("A matching strings file for locale '%s' is '%s'", locale.toLanguageTag(),
                languageLocale.toLanguageTag()));
        }
      }

      // Finally, add the default locale if necessary
      Locale fallbackLocale = getFallbackLocale();

      if (!matchingLocales.contains(fallbackLocale)) {
        matchingLocales.add(fallbackLocale);

        localizedStrings = getLocalizedStringsByLocale().get(fallbackLocale);

        if (localizedStrings != null) {
          localizedStringSources.add(new LocalizedStringSource(fallbackLocale, getLocalizedStringsByKeyByLocale().get(fallbackLocale)));

          if (logger.isLoggable(Level.FINER))
            logger.finer(format("A matching strings file for locale '%s' is fallback '%s'",
                locale.toLanguageTag(), fallbackLocale.toLanguageTag()));
        }
      }

      return Collections.unmodifiableList(localizedStringSources);
    });
  }

  /**
   * Gets the fallback language code.
   *
   * @return the fallback language code, not null
   */
  @Nonnull
  public String getFallbackLanguageCode() {
    return fallbackLanguageCode;
  }

  /**
   * Gets the set of localized strings for each locale.
   *
   * @return the set of localized strings for each locale, not null
   */
  @Nonnull
  public Map<Locale, Set<LocalizedString>> getLocalizedStringsByLocale() {
    return localizedStringsByLocale;
  }

  /**
   * Gets the locale supplier.
   *
   * @return the locale supplier, not null
   */
  @Nonnull
  protected Optional<Supplier<Locale>> getLocaleSupplier() {
    return Optional.ofNullable(localeSupplier);
  }

  /**
   * Gets the language ranges supplier.
   *
   * @return the language ranges supplier, not null
   */
  @Nonnull
  protected Optional<Supplier<List<LanguageRange>>> getLanguageRangesSupplier() {
    return Optional.ofNullable(languageRangesSupplier);
  }

  /**
   * Gets the strategy for handling string lookup failures.
   *
   * @return the strategy for handling string lookup failures, not null
   */
  @Nonnull
  public FailureMode getFailureMode() {
    return failureMode;
  }

  /**
   * Gets the fallback locale.
   *
   * @return the fallback locale, not null
   */
  @Nonnull
  protected Locale getFallbackLocale() {
    return fallbackLocale;
  }

  /**
   * Gets the locale to use if one was not explicitly provided.
   *
   * @return the implicit locale to use, not null
   */
  @Nonnull
  protected Locale getImplicitLocale() {
    Locale locale = null;

    if (getLocaleSupplier().isPresent()) {
      locale = getLocaleSupplier().get().get();
    } else if (getLanguageRangesSupplier().isPresent()) {
      List<LanguageRange> languageRanges = getLanguageRangesSupplier().get().get();

      if (languageRanges != null)
        locale = Locale.lookup(languageRanges, getLocalizedStringsByLocale().keySet());
    }

    return locale == null ? getFallbackLocale() : locale;
  }

  /**
   * Gets the string interpolator used to merge placeholders into translations.
   *
   * @return the string interpolator, not null
   */
  @Nonnull
  protected StringInterpolator getStringInterpolator() {
    return stringInterpolator;
  }

  /**
   * Gets the expression evaluator used to determine if alternative expressions match the evaluation context.
   *
   * @return the expression evaluator, not null
   */
  @Nonnull
  protected ExpressionEvaluator getExpressionEvaluator() {
    return expressionEvaluator;
  }

  /**
   * Gets our "master" cache of localized strings by key by locale.
   *
   * @return the cache of localized strings by key by locale, not null
   */
  @Nonnull
  protected Map<Locale, Map<String, LocalizedString>> getLocalizedStringsByKeyByLocale() {
    return localizedStringsByKeyByLocale;
  }

  /**
   * Get the "runtime" generated map of locales to localized string sources.
   *
   * @return the map of locales to localized string sources, not null
   */
  @Nonnull
  protected ConcurrentHashMap<Locale, List<LocalizedStringSource>> getLocalizedStringSourcesByLocale() {
    return localizedStringSourcesByLocale;
  }

  /**
   * Data structure which holds a locale and the localized strings for it, with the strings mapped by key for fast access.
   *
   * @author <a href="https://revetkn.com"><NAME></a>
   */
  @Immutable
  static class LocalizedStringSource {
    @Nonnull
    private final Locale locale;
    @Nonnull
    private final Map<String, LocalizedString> localizedStringsByKey;

    /**
     * Constructs a localized string source with the given locale and map of keys to localized strings.
     *
     * @param locale                the locale for these localized strings, not null
     * @param localizedStringsByKey localized strings by translation key, not null
     */
    public LocalizedStringSource(@Nonnull Locale locale, @Nonnull Map<String, LocalizedString> localizedStringsByKey) {
      requireNonNull(locale);
      requireNonNull(localizedStringsByKey);

      this.locale = locale;
      this.localizedStringsByKey = localizedStringsByKey;
    }

    /**
     * Generates a {@code String} representation of this object.
     *
     * @return a string representation of this object, not null
     */
    @Override
    @Nonnull
    public String toString() {
      return format("%s{locale=%s, localizedStringsByKey=%s", getClass().getSimpleName(), getLocale(), getLocalizedStringsByKey());
    }

    /**
     * Checks if this object is equal to another one.
     *
     * @param other the object to check, null returns false
     * @return true if this is equal to the other object, false otherwise
     */
    @Override
    public boolean equals(@Nullable Object other) {
      if (this == other)
        return true;

      if (other == null || !getClass().equals(other.getClass()))
        return false;

      LocalizedStringSource localizedStringSource = (LocalizedStringSource) other;

      return Objects.equals(getLocale(), localizedStringSource.getLocale())
          && Objects.equals(getLocalizedStringsByKey(), localizedStringSource.getLocalizedStringsByKey());
    }

    /**
     * A hash code for this object.
     *
     * @return a suitable hash code
     */
    @Override
    public int hashCode() {
      return Objects.hash(getLocale(), getLocalizedStringsByKey());
    }

    @Nonnull
    public Locale getLocale() {
      return locale;
    }

    @Nonnull
    public Map<String, LocalizedString> getLocalizedStringsByKey() {
      return localizedStringsByKey;
    }
  }

  /**
   * Strategies for handling localized string lookup failures.
   */
  public enum FailureMode {
    /**
     * The system will attempt a series of fallbacks in order to not throw an exception at runtime.
     * <p>
     * This mode is useful for production, where we often want program execution to continue in the face of
     * localization errors.
     */
    USE_FALLBACK,
    /**
     * The system will throw an exception if a localization is missing for the specified locale.
     * <p>
     * This mode is useful for testing, since problems are uncovered right away when execution halts.
     */
    FAIL_FAST
  }

  /**
   * Builder used to construct instances of {@link DefaultStrings}.
   * <p>
   * You cannot provide both a {@code localeSupplier} and a {@code languageRangesSupplier} - you must choose one or neither.
   * <p>
   * This class is intended for use by a single thread.
   *
   * @author <a href="https://revetkn.com"><NAME></a>
   */
  @NotThreadSafe
  public static class Builder {
    @Nonnull
    private final String fallbackLanguageCode;
    @Nonnull
    private final Supplier<Map<Locale, ? extends Iterable<LocalizedString>>> localizedStringSupplier;
    @Nullable
    private Supplier<Locale> localeSupplier;
    @Nullable
    private Supplier<List<LanguageRange>> languageRangesSupplier;
    @Nullable
    private FailureMode failureMode;

    /**
     * Constructs a strings builder with a default language code and localized string supplier.
     * <p>
     * The fallback language code must be an ISO 639 alpha-2 or alpha-3 language code.
     * When a language has both an alpha-2 code and an alpha-3 code, the alpha-2 code must be used.
     *
     * @param fallbackLanguageCode    fallback language code, not null
     * @param localizedStringSupplier supplier of localized strings, not null
     */
    public Builder(@Nonnull String fallbackLanguageCode, @Nonnull Supplier<Map<Locale, ? extends Iterable<LocalizedString>>> localizedStringSupplier) {
      requireNonNull(fallbackLanguageCode);
      requireNonNull(localizedStringSupplier);

      this.fallbackLanguageCode = fallbackLanguageCode;
      this.localizedStringSupplier = localizedStringSupplier;
    }

    /**
     * Applies a locale supplier to this builder.
     *
     * @param localeSupplier locale supplier, may be null
     * @return this builder instance, useful for chaining. not null
     */
    @Nonnull
    public Builder localeSupplier(@Nullable Supplier<Locale> localeSupplier) {
      this.localeSupplier = localeSupplier;
      return this;
    }

    /**
     * Applies a supplier of language ranges to this builder.
     *
     * @param languageRangesSupplier language ranges supplier, may be null
     * @return this builder instance, useful for chaining. not null
     */
    @Nonnull
    public Builder languageRangesSupplier(@Nullable Supplier<List<LanguageRange>> languageRangesSupplier) {
      this.languageRangesSupplier = languageRangesSupplier;
      return this;
    }

    /**
     * Applies a failure mode to this builder.
     *
     * @param failureMode strategy for dealing with lookup failures, may be null
     * @return this builder instance, useful for chaining. not null
     */
    @Nonnull
    public Builder failureMode(@Nullable FailureMode failureMode) {
      this.failureMode = failureMode;
      return this;
    }

    /**
     * Constructs an instance of {@link DefaultStrings}.
     *
     * @return an instance of {@link DefaultStrings}, not null
     */
    @Nonnull
    public DefaultStrings build() {
      return new DefaultStrings(fallbackLanguageCode, localizedStringSupplier, localeSupplier, languageRangesSupplier, failureMode);
    }
  }
}

<|start_filename|>src/main/java/com/lokalized/TokenType.java<|end_filename|>
/*
 * Copyright 2017 Product Mog LLC.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.lokalized;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.Arrays;
import java.util.Collections;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

/**
 * @author <a href="https://revetkn.com"><NAME></a>
 */
enum TokenType {
  VARIABLE(null),
  NUMBER(null),
  GROUP_START("("),
  GROUP_END(")"),
  AND("&&"),
  OR("||"),
  LESS_THAN("<"),
  GREATER_THAN(">"),
  EQUAL_TO("=="),
  NOT_EQUAL_TO("!="),
  LESS_THAN_OR_EQUAL_TO("<="),
  GREATER_THAN_OR_EQUAL_TO(">="),
  CARDINALITY_ZERO("CARDINALITY_ZERO"),
  CARDINALITY_ONE("CARDINALITY_ONE"),
  CARDINALITY_TWO("CARDINALITY_TWO"),
  CARDINALITY_FEW("CARDINALITY_FEW"),
  CARDINALITY_MANY("CARDINALITY_MANY"),
  CARDINALITY_OTHER("CARDINALITY_OTHER"),
  ORDINALITY_ZERO("ORDINALITY_ZERO"),
  ORDINALITY_ONE("ORDINALITY_ONE"),
  ORDINALITY_TWO("ORDINALITY_TWO"),
  ORDINALITY_FEW("ORDINALITY_FEW"),
  ORDINALITY_MANY("ORDINALITY_MANY"),
  ORDINALITY_OTHER("ORDINALITY_OTHER"),
  MASCULINE("MASCULINE"),
  FEMININE("FEMININE"),
  NEUTER("NEUTER");

  @Nullable
  private final String symbol;
  @Nonnull
  private static final Set<TokenType> TOKEN_TYPES_WITH_DEFINED_SYMBOL;
  @Nonnull
  private static final Set<TokenType> TOKEN_TYPES_WITH_UNDEFINED_SYMBOL;
  @Nonnull
  private static final Map<String, TokenType> TOKEN_TYPES_BY_SYMBOL;

  static {
    TOKEN_TYPES_WITH_DEFINED_SYMBOL = Collections.unmodifiableSet(Arrays.asList(TokenType.values()).stream()
        .filter(tokenType -> tokenType.getSymbol().isPresent())
        .collect(Collectors.toSet()));

    TOKEN_TYPES_WITH_UNDEFINED_SYMBOL = Collections.unmodifiableSet(Arrays.asList(TokenType.values()).stream()
        .filter(tokenType -> !tokenType.getSymbol().isPresent())
        .collect(Collectors.toSet()));

    TOKEN_TYPES_BY_SYMBOL = Collections.unmodifiableMap(TOKEN_TYPES_WITH_DEFINED_SYMBOL.stream()
        .collect(Collectors.toMap(tokenType -> tokenType.getSymbol().get(), tokenType -> tokenType)));
  }

  TokenType(@Nullable String symbol) {
    this.symbol = symbol;
  }

  @Nonnull
  public Optional<String> getSymbol() {
    return Optional.ofNullable(symbol);
  }

  @Nonnull
  public static Set<TokenType> getTokenTypesWithDefinedSymbol() {
    return TOKEN_TYPES_WITH_DEFINED_SYMBOL;
  }

  @Nonnull
  public static Set<TokenType> getTokenTypesWithUndefinedSymbol() {
    return TOKEN_TYPES_WITH_UNDEFINED_SYMBOL;
  }

  @Nonnull
  public static Map<String, TokenType> getTokenTypesBySymbol() {
    return TOKEN_TYPES_BY_SYMBOL;
  }
}

<|start_filename|>src/main/java/com/lokalized/ExpressionEvaluator.java<|end_filename|>
/*
 * Copyright 2017 Product Mog LLC.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.lokalized;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.annotation.concurrent.ThreadSafe;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Deque;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

import static java.lang.String.format;
import static java.util.Objects.requireNonNull;

/**
 * Evaluator for localized string "alternative" expressions.
 * <p>
 * Rough grammar:
 * <p>
 * <pre>
 * EXPRESSION = OPERAND COMPARISON_OPERATOR OPERAND | "(" EXPRESSION ")" | EXPRESSION BOOLEAN_OPERATOR EXPRESSION ;
 * OPERAND = VARIABLE | LANGUAGE_FORM | NUMBER ;
 * LANGUAGE_FORM = CARDINALITY | ORDINALITY | GENDER ;
 * CARDINALITY = "CARDINALITY_ZERO" | "CARDINALITY_ONE" | "CARDINALITY_TWO" | "CARDINALITY_FEW" | "CARDINALITY_MANY" | "CARDINALITY_OTHER" ;
 * ORDINALITY = "ORDINALITY_ZERO" | "ORDINALITY_ONE" | "ORDINALITY_TWO" | "ORDINALITY_FEW" | "ORDINALITY_MANY" | "ORDINALITY_OTHER" ;
 * GENDER = "MASCULINE" | "FEMININE" | "NEUTER" ;
 * VARIABLE = { alphabetic character | digit } ;
 * BOOLEAN_OPERATOR = "&&" | "||" ;
 * COMPARISON_OPERATOR = "<" | ">" | "<=" | ">=" | "==" | "!=" ;
 * </pre>
 *
 * @author <a href="https://revetkn.com"><NAME></a>
 */
@ThreadSafe
class ExpressionEvaluator {
  @Nonnull
  private static final Set<TokenType> CARDINALITY_TOKEN_TYPES;
  @Nonnull
  private static final Set<TokenType> ORDINALITY_TOKEN_TYPES;
  @Nonnull
  private static final Set<TokenType> GENDER_TOKEN_TYPES;
  @Nonnull
  private static final Set<TokenType> COMPARISON_OPERATOR_TOKEN_TYPES;
  @Nonnull
  private static final Set<TokenType> BOOLEAN_OPERATOR_TOKEN_TYPES;
  @Nonnull
  private static final Set<TokenType> OPERAND_TOKEN_TYPES;
  @Nonnull
  private static final Set<TokenType> OPERATOR_TOKEN_TYPES;
  @Nonnull
  private static final Token TRUE_RESULT_TOKEN;
  @Nonnull
  private static final Token FALSE_RESULT_TOKEN;

  @Nonnull
  private final ExpressionTokenizer expressionTokenizer;

  static {
    CARDINALITY_TOKEN_TYPES = Collections.unmodifiableSet(new HashSet<TokenType>() {
      {
        add(TokenType.CARDINALITY_ZERO);
        add(TokenType.CARDINALITY_ONE);
        add(TokenType.CARDINALITY_TWO);
        add(TokenType.CARDINALITY_FEW);
        add(TokenType.CARDINALITY_MANY);
        add(TokenType.CARDINALITY_OTHER);
      }
    });

    ORDINALITY_TOKEN_TYPES = Collections.unmodifiableSet(new HashSet<TokenType>() {
      {
        add(TokenType.ORDINALITY_ZERO);
        add(TokenType.ORDINALITY_ONE);
        add(TokenType.ORDINALITY_TWO);
        add(TokenType.ORDINALITY_FEW);
        add(TokenType.ORDINALITY_MANY);
        add(TokenType.ORDINALITY_OTHER);
      }
    });

    GENDER_TOKEN_TYPES = Collections.unmodifiableSet(new HashSet<TokenType>() {
      {
        add(TokenType.MASCULINE);
        add(TokenType.FEMININE);
        add(TokenType.NEUTER);
      }
    });

    COMPARISON_OPERATOR_TOKEN_TYPES = Collections.unmodifiableSet(new HashSet<TokenType>() {
      {
        add(TokenType.LESS_THAN);
        add(TokenType.LESS_THAN_OR_EQUAL_TO);
        add(TokenType.GREATER_THAN);
        add(TokenType.GREATER_THAN_OR_EQUAL_TO);
        add(TokenType.EQUAL_TO);
        add(TokenType.NOT_EQUAL_TO);
      }
    });

    BOOLEAN_OPERATOR_TOKEN_TYPES = Collections.unmodifiableSet(new HashSet<TokenType>() {
      {
        add(TokenType.AND);
        add(TokenType.OR);
      }
    });

    Set<TokenType> operandTokenTypes = new HashSet<>();
    operandTokenTypes.addAll(CARDINALITY_TOKEN_TYPES);
    operandTokenTypes.addAll(ORDINALITY_TOKEN_TYPES);
    operandTokenTypes.addAll(GENDER_TOKEN_TYPES);
    operandTokenTypes.add(TokenType.VARIABLE);
    operandTokenTypes.add(TokenType.NUMBER);

    OPERAND_TOKEN_TYPES = Collections.unmodifiableSet(operandTokenTypes);

    Set<TokenType> operatorTokenTypes = new HashSet<>();
    operatorTokenTypes.addAll(COMPARISON_OPERATOR_TOKEN_TYPES);
    operatorTokenTypes.addAll(BOOLEAN_OPERATOR_TOKEN_TYPES);

    OPERATOR_TOKEN_TYPES = Collections.unmodifiableSet(operatorTokenTypes);

    // TRUE and FALSE are magic tokens used at RPN evaluation time to hold result of binary operator expressions
    TRUE_RESULT_TOKEN = new Token(TokenType.VARIABLE, "TRUE");
    FALSE_RESULT_TOKEN = new Token(TokenType.VARIABLE, "FALSE");
  }

  /**
   * Constructs an expression evaluation with a default tokenizer.
   */
  public ExpressionEvaluator() {
    this(null);
  }

  /**
   * Constructs an expression evaluation with the provided tokenizer.
   * <p>
   * If no tokenizer is provided, a default will be used.
   *
   * @param expressionTokenizer the expression tokenizer to use, may be null
   */
  public ExpressionEvaluator(@Nullable ExpressionTokenizer expressionTokenizer) {
    this.expressionTokenizer = expressionTokenizer == null ? new ExpressionTokenizer() : null;
  }

  /**
   * Evaluates an expression given a locale.
   * <p>
   * Locale is necessary for plural cardinality and ordinal form evaluation.
   *
   * @param expression the expression to evaluate, not null
   * @param locale     the locale to use for evaluation, not null
   * @return the result of expression evaluation, not null
   * @throws ExpressionEvaluationException if an error occurs while evaluating the expression
   */
  @Nonnull
  public Boolean evaluate(@Nonnull String expression, @Nonnull Locale locale) {
    return evaluate(expression, null, locale);
  }

  /**
   * Evaluates an expression given context and locale.
   * <p>
   * Locale is necessary for plural cardinality and ordinal form evaluation.
   *
   * @param expression the expression to evaluate, not null
   * @param context    the context for the expression, may be null
   * @param locale     the locale to use for evaluation, not null
   * @return the result of expression evaluation, not null
   * @throws ExpressionEvaluationException if an error occurs while evaluating the expression
   */
  @Nonnull
  public Boolean evaluate(@Nonnull String expression, @Nullable Map<String, Object> context, @Nonnull Locale locale) {
    requireNonNull(expression);
    requireNonNull(locale);

    if (context == null)
      context = Collections.emptyMap();

    List<Token> tokens = getExpressionTokenizer().extractTokens(expression);
    tokens = convertTokensToReversePolishNotation(tokens);
    return evaluateReversePolishNotationTokens(tokens, context, locale);
  }

  /**
   * Given an list of tokens in infix notation, convert it to postfix (RPN).
   * <p>
   * The input list is not modified.
   * <p>
   * This implementation uses <a href="https://en.wikipedia.org/wiki/Shunting-yard_algorithm">Dijkstra's shunting-yard algorithm</a>.
   *
   * @param tokens the tokens to convert to RPN, not null
   * @return the input tokens in RPN format, not null
   * @throws ExpressionEvaluationException if an error occurs while converting to RPN
   */
  @Nonnull
  protected List<Token> convertTokensToReversePolishNotation(@Nonnull List<Token> tokens) {
    requireNonNull(tokens);

    List<Token> outputTokens = new ArrayList<>(tokens.size());
    Deque<Token> operatorStack = new ArrayDeque<>();

    // Perform Dijkstra's shunting-yard algorithm
    for (Token token : tokens) {
      // If the token is a number, then add it to the output queue.
      if (isOperand(token))
        outputTokens.add(token);

        // If the token is an operator, o1, then:
      else if (isOperator(token)) {
        // While there is an operator token, o2, at the top of the stack, and o1's precedence is less than or equal to
        // that of o2, then pop o2 off the stack, onto the output queue
        while (!operatorStack.isEmpty() && isOperator(operatorStack.peek())
            && (precedence(token) <= precedence(operatorStack.peek())))
          outputTokens.add(operatorStack.pop());

        // Push o1 onto the stack.
        operatorStack.push(token);
      }

      // If the token is a left parenthesis, then push it onto the stack.
      else if (token.getTokenType() == TokenType.GROUP_START) {
        operatorStack.push(token);
      }

      // If the token is a right parenthesis:
      else if (token.getTokenType() == TokenType.GROUP_END) {
        // Until the token at the top of the stack is a left parenthesis, pop operators off the stack onto the output queue.
        while (!operatorStack.isEmpty() && operatorStack.peek().getTokenType() != TokenType.GROUP_START)
          outputTokens.add(operatorStack.pop());

        // If the stack runs out without finding a left parenthesis, then there are mismatched parentheses.
        if (operatorStack.isEmpty())
          throw new ExpressionEvaluationException(format("Unbalanced %s detected", TokenType.GROUP_END.getSymbol().get()));

        // Pop the left parenthesis from the stack, but not onto the output queue.
        operatorStack.pop();
      }
    }

    // When there are no more tokens to read:

    // While there are still operator tokens in the stack:
    while (!operatorStack.isEmpty()) {
      // If the operator token on the top of the stack is a parenthesis, then there are mismatched parentheses.
      if (!operatorStack.isEmpty()
          && (operatorStack.peek().getTokenType() == TokenType.GROUP_START || operatorStack.peek().getTokenType() == TokenType.GROUP_END))
        throw new ExpressionEvaluationException(format("Unbalanced %s detected", operatorStack.peek().getSymbol()));

      // Pop the operator onto the output queue.
      outputTokens.add(operatorStack.pop());
    }

    return Collections.unmodifiableList(outputTokens);
  }

  /**
   * Given a list of tokens in RPN format, evaluate the expression they comprise against the given context
   * and locale and return a true or false result.
   * <p>
   * RPN evaluation algorithm is outlined at
   * <a href="http://en.wikipedia.org/wiki/Reverse_Polish_notation">http://en.wikipedia.org/wiki/Reverse_Polish_notation</a>.
   *
   * @param tokens  the RPN-format tokens to evaluate, not null
   * @param context the context for the expression, not null
   * @param locale  the locale to use for evaluation, not null
   * @return the result of expression evaluation, not null
   * @throws ExpressionEvaluationException if an error occurs while evaluating the expression
   */
  @Nonnull
  protected Boolean evaluateReversePolishNotationTokens(@Nonnull List<Token> tokens,
                                                        @Nonnull Map<String, Object> context, @Nonnull Locale locale) {
    requireNonNull(tokens);
    requireNonNull(context);
    requireNonNull(locale);

    Deque<Token> valueStack = new ArrayDeque<>();

    // Evaluate RPN tokens.
    // See http://en.wikipedia.org/wiki/Reverse_Polish_notation
    for (Token token : tokens) {
      // If the token is a value
      if (isOperand(token)) {
        // Push it onto the stack.
        valueStack.push(token);
      } else if (isOperator(token)) {
        // It is known a priori that an operator takes 2 arguments.
        // If there are fewer than 2 values on the stack, the user has not input sufficient values in the expression.
        if (valueStack.size() < 2)
          throw new ExpressionEvaluationException(format("Insufficient arguments provided for operator '%s' (%s)",
              token.getSymbol(), valueStack.stream().map(operand -> operand.getSymbol()).collect(Collectors.toList())));

        // Else, Pop the top 2 values from the stack.
        Token rightHandOperand = valueStack.pop();
        Token leftHandOperand = valueStack.pop();

        // Evaluate the operator, with the values as arguments.
        Token result = evaluateBinaryOperator(leftHandOperand, token, rightHandOperand, context, locale);

        // Push the returned results, if any, back onto the stack.
        valueStack.push(result);
      } else {
        throw new ExpressionEvaluationException(format("Unexpected symbol encountered: '%s'", token.getSymbol()));
      }
    }

    // If there is only one value in the stack, that value is the result of the calculation.
    if (valueStack.size() == 1) {
      Token resultToken = valueStack.pop();

      if (resultToken == TRUE_RESULT_TOKEN)
        return true;
      if (resultToken == FALSE_RESULT_TOKEN)
        return false;

      throw new ExpressionEvaluationException(format("Unexpected final symbol encountered: '%s'", resultToken.getSymbol()));
    }

    // Otherwise, there are more values in the stack. The user input has too many values.
    throw new ExpressionEvaluationException(format("Unexpected extra values exist on the stack: %s", valueStack
        .stream().map(operand -> operand.getSymbol()).collect(Collectors.toList())));
  }

  /**
   * Applies a binary operator to the given left- and right-hand operands.
   * <p>
   * A special {@link #TRUE_RESULT_TOKEN} or {@link #FALSE_RESULT_TOKEN} is returned to indicate the result.
   *
   * @param leftHandOperand  the left-hand-side token, not null
   * @param operator         the binary operator to apply, not null
   * @param rightHandOperand the right-hand-side token, not null
   * @param context          the context for the expression, not null
   * @param locale           the locale to use for evaluation, not null
   * @return the result of the binary operator evaluation (magic TRUE_RESULT_TOKEN or FALSE_RESULT_TOKEN), not null
   * @throws ExpressionEvaluationException if an error occurs while evaluating the operator
   */
  protected Token evaluateBinaryOperator(@Nonnull Token leftHandOperand, @Nonnull Token operator, @Nonnull Token rightHandOperand,
                                         @Nonnull Map<String, Object> context, @Nonnull Locale locale) {
    requireNonNull(leftHandOperand);
    requireNonNull(operator);
    requireNonNull(rightHandOperand);
    requireNonNull(context);
    requireNonNull(locale);

    if (isBooleanOperator(operator)) {
      boolean lhsValue = booleanValue(leftHandOperand);
      boolean rhsValue = booleanValue(rightHandOperand);

      if (operator.getTokenType() == TokenType.AND)
        return lhsValue && rhsValue ? TRUE_RESULT_TOKEN : FALSE_RESULT_TOKEN;
      else if (operator.getTokenType() == TokenType.OR)
        return lhsValue || rhsValue ? TRUE_RESULT_TOKEN : FALSE_RESULT_TOKEN;
      else
        throw new ExpressionEvaluationException(format("Expected boolean operator (one of %s) but encountered '%s'",
            BOOLEAN_OPERATOR_TOKEN_TYPES.stream()
                .map(tokenType -> tokenType.getSymbol().get())
                .collect(Collectors.toList()), operator));
    } else if (isComparisonOperator(operator)) {
      OperandType lhsOperandType = operandType(leftHandOperand, context);
      OperandType rhsOperandType = operandType(rightHandOperand, context);

      // If either side is unknown we evaluate to false
      // TODO: fail-fast here?
      if (lhsOperandType == OperandType.UNKNOWN || rhsOperandType == OperandType.UNKNOWN)
        return FALSE_RESULT_TOKEN;

      // null == null
      if (lhsOperandType == OperandType.NULL && rhsOperandType == OperandType.NULL)
        return TRUE_RESULT_TOKEN;

      // null on either side evaluates to false (no coercing to some default value)
      if (lhsOperandType == OperandType.NULL || rhsOperandType == OperandType.NULL)
        return FALSE_RESULT_TOKEN;

      // Number (operators: any)
      if (lhsOperandType == OperandType.NUMBER && rhsOperandType == OperandType.NUMBER) {
        double lhsValue = doubleFromOperand(leftHandOperand, context);
        double rhsValue = doubleFromOperand(rightHandOperand, context);
        boolean result = false;

        if (operator.getTokenType() == TokenType.LESS_THAN)
          result = lhsValue < rhsValue;
        else if (operator.getTokenType() == TokenType.LESS_THAN_OR_EQUAL_TO)
          result = lhsValue <= rhsValue;
        else if (operator.getTokenType() == TokenType.GREATER_THAN)
          result = lhsValue > rhsValue;
        else if (operator.getTokenType() == TokenType.GREATER_THAN_OR_EQUAL_TO)
          result = lhsValue >= rhsValue;
        else if (operator.getTokenType() == TokenType.EQUAL_TO)
          result = lhsValue == rhsValue;
        else if (operator.getTokenType() == TokenType.NOT_EQUAL_TO)
          result = lhsValue != rhsValue;
        else
          throw new ExpressionEvaluationException(format("Encountered unexpected operator '%s'", operator.getSymbol()));

        return result ? TRUE_RESULT_TOKEN : FALSE_RESULT_TOKEN;
      }

      // Gender (operators: ==, !=)
      if (lhsOperandType == OperandType.GENDER && rhsOperandType == OperandType.GENDER) {
        if (!(operator.getTokenType() == TokenType.EQUAL_TO || operator.getTokenType() == TokenType.NOT_EQUAL_TO))
          throw new ExpressionEvaluationException(
              format(
                  "You may only use the '%s' and '%s' operators when performing gender comparisons. Offending comparison: '%s %s %s'",
                  TokenType.EQUAL_TO.getSymbol().get(), TokenType.NOT_EQUAL_TO.getSymbol().get(), leftHandOperand.getSymbol(),
                  operator.getSymbol(), rightHandOperand.getSymbol()));

        Gender lhsValue = genderFromOperand(leftHandOperand, context);
        Gender rhsValue = genderFromOperand(rightHandOperand, context);
        boolean result = false;

        if (operator.getTokenType() == TokenType.EQUAL_TO)
          result = lhsValue == rhsValue;
        if (operator.getTokenType() == TokenType.NOT_EQUAL_TO)
          result = lhsValue != rhsValue;

        return result ? TRUE_RESULT_TOKEN : FALSE_RESULT_TOKEN;
      }

      // Cardinality (operators: ==, !=)
      if (lhsOperandType == OperandType.CARDINALITY || rhsOperandType == OperandType.CARDINALITY) {
        Cardinality lhsValue = cardinalityFromOperand(leftHandOperand, context, locale);
        Cardinality rhsValue = cardinalityFromOperand(rightHandOperand, context, locale);

        boolean result = false;

        if (operator.getTokenType() == TokenType.EQUAL_TO)
          result = lhsValue == rhsValue;
        if (operator.getTokenType() == TokenType.NOT_EQUAL_TO)
          result = lhsValue != rhsValue;

        return result ? TRUE_RESULT_TOKEN : FALSE_RESULT_TOKEN;
      }

      // Ordinality (operators: ==, !=)
      if (lhsOperandType == OperandType.ORDINALITY || rhsOperandType == OperandType.ORDINALITY) {
        Ordinality lhsValue = ordinalityFromOperand(leftHandOperand, context, locale);
        Ordinality rhsValue = ordinalityFromOperand(rightHandOperand, context, locale);

        boolean result = false;

        if (operator.getTokenType() == TokenType.EQUAL_TO)
          result = lhsValue == rhsValue;
        if (operator.getTokenType() == TokenType.NOT_EQUAL_TO)
          result = lhsValue != rhsValue;

        return result ? TRUE_RESULT_TOKEN : FALSE_RESULT_TOKEN;
      }

      throw new ExpressionEvaluationException(format(
          "Unable to evaluate expression '%s %s %s'. Operand types %s and %s are incompatible", leftHandOperand.getSymbol(),
          operator.getSymbol(), rightHandOperand.getSymbol(), lhsOperandType.name(), rhsOperandType.name()));
    } else {
      throw new ExpressionEvaluationException(format("Expected operator but encountered '%s'", operator.getSymbol()));
    }
  }

  /**
   * Determines the evaluation precedence of a token.
   *
   * @param token the token to check, not null
   * @return the precedence value for the token, not null
   * @throws ExpressionEvaluationException if the token does not support precedence
   */
  @Nonnull
  protected Integer precedence(@Nonnull Token token) {
    requireNonNull(token);

    if (COMPARISON_OPERATOR_TOKEN_TYPES.contains(token.getTokenType()))
      return 1;
    if (BOOLEAN_OPERATOR_TOKEN_TYPES.contains(token.getTokenType()))
      return 0;

    throw new ExpressionEvaluationException(format("Cannot determine precedence for '%s'", token.getSymbol()));
  }

  /**
   * Does the specified token represent an operand?
   *
   * @param token the token to check, not null
   * @return whether the token represents an operand, not null
   */
  @Nonnull
  protected Boolean isOperand(@Nonnull Token token) {
    requireNonNull(token);
    return OPERAND_TOKEN_TYPES.contains(token.getTokenType());
  }

  /**
   * Does the specified token represent an operator?
   *
   * @param token the token to check, not null
   * @return whether the token represents an operator, not null
   */
  @Nonnull
  protected Boolean isOperator(@Nonnull Token token) {
    requireNonNull(token);
    return OPERATOR_TOKEN_TYPES.contains(token.getTokenType());
  }

  /**
   * Does the specified token represent a boolean operator?
   *
   * @param token the token to check, not null
   * @return whether the token represents a boolean operator, not null
   */
  @Nonnull
  protected Boolean isBooleanOperator(@Nonnull Token token) {
    requireNonNull(token);
    return BOOLEAN_OPERATOR_TOKEN_TYPES.contains(token.getTokenType());
  }

  /**
   * Does the specified token represent a comparison operator?
   *
   * @param token the token to check, not null
   * @return whether the token represents a comparison operation, not null
   */
  @Nonnull
  protected Boolean isComparisonOperator(@Nonnull Token token) {
    requireNonNull(token);
    return COMPARISON_OPERATOR_TOKEN_TYPES.contains(token.getTokenType());
  }

  /**
   * Does the specified token represent a boolean result?
   *
   * @param token the token to check, not null
   * @return whether the token represents a boolean result, not null
   */
  @Nonnull
  protected Boolean isBooleanResult(@Nonnull Token token) {
    requireNonNull(token);
    return token == TRUE_RESULT_TOKEN || token == FALSE_RESULT_TOKEN;
  }

  /**
   * Does the specified token represent a gender?
   *
   * @param token the token to check, not null
   * @return whether the token represents a gender, not null
   */
  @Nonnull
  protected Boolean isGender(@Nonnull Token token) {
    requireNonNull(token);
    return GENDER_TOKEN_TYPES.contains(token.getTokenType());
  }

  /**
   * Does the specified token represent a plural cardinality?
   *
   * @param token the token to check, not null
   * @return whether the token represents a plural cardinality, not null
   */
  @Nonnull
  protected Boolean isCardinality(@Nonnull Token token) {
    requireNonNull(token);
    return CARDINALITY_TOKEN_TYPES.contains(token.getTokenType());
  }

  /**
   * Does the specified token represent a plural ordinality?
   *
   * @param token the token to check, not null
   * @return whether the token represents a plural ordinality, not null
   */
  @Nonnull
  protected Boolean isOrdinality(@Nonnull Token token) {
    requireNonNull(token);
    return ORDINALITY_TOKEN_TYPES.contains(token.getTokenType());
  }

  /**
   * Determines the type of an operand.
   *
   * @param operand the operand to examine, not null
   * @param context the context for the expression, not null
   * @return the type of the operand (or {@link OperandType#UNKNOWN} if indeterminate), not null
   */
  @Nonnull
  protected OperandType operandType(@Nonnull Token operand, @Nonnull Map<String, Object> context) {
    requireNonNull(operand);
    requireNonNull(context);

    if (operand.getTokenType() == TokenType.NUMBER)
      return OperandType.NUMBER;
    if (isCardinality(operand))
      return OperandType.CARDINALITY;
    if (isOrdinality(operand))
      return OperandType.ORDINALITY;
    if (isGender(operand))
      return OperandType.GENDER;

    if (operand.getTokenType() == TokenType.VARIABLE) {
      Object value = context.get(operand.getSymbol());

      if (value instanceof Optional)
        value = ((Optional<?>) value).orElse(null);

      if (value == null)
        return OperandType.NULL;
      if (value instanceof Number)
        return OperandType.NUMBER;
      if (value instanceof Cardinality)
        return OperandType.CARDINALITY;
      if (value instanceof Ordinality)
        return OperandType.ORDINALITY;
      if (value instanceof Gender)
        return OperandType.GENDER;
    }

    return OperandType.UNKNOWN;
  }

  /**
   * Determines the double value of an operand.
   *
   * @param operand the operand to examine, not null
   * @param context the context for the expression, not null
   * @return the double value of the operand, not null
   * @throws ExpressionEvaluationException if unable to determine double value (operand is of invalid type, etc.)
   */
  @Nonnull
  protected Double doubleFromOperand(@Nonnull Token operand, @Nonnull Map<String, Object> context) {
    requireNonNull(operand);
    requireNonNull(context);

    if (operand.getTokenType() == TokenType.NUMBER)
      return Double.parseDouble(operand.getSymbol());

    if (operand.getTokenType() == TokenType.VARIABLE) {
      Object value = context.get(operand.getSymbol());

      if (value instanceof Optional)
        value = ((Optional<?>) value).orElse(null);

      if (value instanceof Number)
        return ((Number) value).doubleValue();
    }

    throw new ExpressionEvaluationException(format("Unable to extract numeric value from '%s'", operand.getSymbol()));
  }

  /**
   * Determines the gender value of an operand.
   *
   * @param operand the operand to examine, not null
   * @param context the context for the expression, not null
   * @return the gender value of the operand, not null
   * @throws ExpressionEvaluationException if unable to determine gender value (operand is of invalid type, etc.)
   */
  @Nonnull
  protected Gender genderFromOperand(@Nonnull Token operand, @Nonnull Map<String, Object> context) {
    requireNonNull(operand);
    requireNonNull(context);

    if (isGender(operand))
      return Gender.getGendersByName().get(operand.getSymbol());

    if (operand.getTokenType() == TokenType.VARIABLE) {
      Object value = context.get(operand.getSymbol());

      if (value instanceof Optional)
        value = ((Optional<?>) value).orElse(null);

      if (value instanceof Gender)
        return (Gender) value;
    }

    throw new ExpressionEvaluationException(format("Unable to extract %s value from '%s'",
        Gender.class.getSimpleName(), operand.getSymbol()));
  }

  /**
   * Determines the plural cardinality of an operand.
   *
   * @param operand the operand to examine, not null
   * @param context the context for the expression, not null
   * @param locale  the locale to use for evaluation, not null
   * @return the plural cardinality of the operand, not null
   * @throws ExpressionEvaluationException if unable to determine plural cardinality value (operand is of invalid type, etc.)
   */
  @Nonnull
  protected Cardinality cardinalityFromOperand(@Nonnull Token operand, @Nonnull Map<String, Object> context, @Nonnull Locale locale) {
    requireNonNull(operand);
    requireNonNull(context);
    requireNonNull(locale);

    if (isCardinality(operand))
      return Cardinality.getCardinalitiesByName().get(LocalizedStringUtils.cardinalityNameForLocalizedStringName(operand.getSymbol()));

    if (operand.getTokenType() == TokenType.NUMBER)
      return Cardinality.forNumber(doubleFromOperand(operand, context), locale);

    if (operand.getTokenType() == TokenType.VARIABLE) {
      Object value = context.get(operand.getSymbol());

      if (value instanceof Optional)
        value = ((Optional<?>) value).orElse(null);

      if (value instanceof Cardinality)
        return (Cardinality) value;
      if (value instanceof Number)
        return Cardinality.forNumber((Number) value, locale);
    }

    throw new ExpressionEvaluationException(format("Unable to extract %s value from '%s'",
        Cardinality.class.getSimpleName(), operand.getSymbol()));
  }

  /**
   * Determines the plural ordinality of an operand.
   *
   * @param operand the operand to examine, not null
   * @param context the context for the expression, not null
   * @param locale  the locale to use for evaluation, not null
   * @return the plural ordinality of the operand, not null
   * @throws ExpressionEvaluationException if unable to determine plural ordinality value (operand is of invalid type, etc.)
   */
  @Nonnull
  protected Ordinality ordinalityFromOperand(@Nonnull Token operand, @Nonnull Map<String, Object> context, @Nonnull Locale locale) {
    requireNonNull(operand);
    requireNonNull(context);
    requireNonNull(locale);

    if (isCardinality(operand))
      return Ordinality.getOrdinalitiesByName().get(LocalizedStringUtils.ordinalityNameForLocalizedStringName(operand.getSymbol()));

    if (operand.getTokenType() == TokenType.NUMBER)
      return Ordinality.forNumber(doubleFromOperand(operand, context), locale);

    if (operand.getTokenType() == TokenType.VARIABLE) {
      Object value = context.get(operand.getSymbol());

      if (value instanceof Optional)
        value = ((Optional<?>) value).orElse(null);

      if (value instanceof Ordinality)
        return (Ordinality) value;
      if (value instanceof Number)
        return Ordinality.forNumber((Number) value, locale);
    }

    throw new ExpressionEvaluationException(format("Unable to extract %s value from '%s'",
        Ordinality.class.getSimpleName(), operand.getSymbol()));
  }

  /**
   * Determines the boolean value of a token.
   *
   * @param token the token to examine, not null
   * @return the boolean value of the token, not null
   * @throws ExpressionEvaluationException if unable to determine boolean value (token is of invalid type, etc.)
   */
  @Nonnull
  protected Boolean booleanValue(@Nonnull Token token) {
    requireNonNull(token);

    if (token == TRUE_RESULT_TOKEN)
      return true;
    if (token == FALSE_RESULT_TOKEN)
      return false;

    throw new ExpressionEvaluationException(format("Expected boolean but encountered '%s'", token.getSymbol()));
  }

  /**
   * Gets the expression tokenizer.
   *
   * @return the expression tokenizer, not null
   */
  @Nonnull
  protected ExpressionTokenizer getExpressionTokenizer() {
    return expressionTokenizer;
  }

  /**
   * Expression operand types.
   *
   * @author <a href="https://revetkn.com"><NAME></a>
   */
  protected enum OperandType {
    NUMBER, GENDER, CARDINALITY, ORDINALITY, NULL, UNKNOWN;
  }
}

<|start_filename|>src/main/java/com/lokalized/Token.java<|end_filename|>
/*
 * Copyright 2017 Product Mog LLC.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.lokalized;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.annotation.concurrent.Immutable;
import java.util.Objects;

import static java.lang.String.format;
import static java.util.Objects.requireNonNull;

/**
 * A localization expression token.
 * <p>
 * A list of tokens is the output of lexical analysis as performed by {@link ExpressionTokenizer}.
 *
 * @author <a href="https://revetkn.com"><NAME></a>
 */
@Immutable
final class Token {
  @Nonnull
  private final TokenType tokenType;
  @Nonnull
  private final String symbol;

  /**
   * Constructs a new token with the given type.
   *
   * @param tokenType what kind of token this is, not null
   * @throws IllegalArgumentException if the token type does not have a predefined symbol
   */
  public Token(@Nonnull TokenType tokenType) {
    requireNonNull(tokenType);

    if (TokenType.getTokenTypesWithUndefinedSymbol().contains(tokenType))
      throw new IllegalArgumentException(format("You must provide a symbol for %s.%s values.",
          TokenType.class.getSimpleName(), tokenType.name()));

    this.tokenType = tokenType;
    this.symbol = tokenType.getSymbol().get();
  }

  /**
   * Constructs a new token with the given type and symbol.
   *
   * @param tokenType what kind of token this is, not null
   * @param symbol    the symbol for this token, not null
   * @throws IllegalArgumentException if the token type has a predefined symbol that does not match the provided symbol
   */
  public Token(@Nonnull TokenType tokenType, @Nonnull String symbol) {
    requireNonNull(tokenType);
    requireNonNull(symbol);

    if (TokenType.getTokenTypesWithDefinedSymbol().contains(tokenType) && !tokenType.getSymbol().get().equals(symbol))
      throw new IllegalArgumentException(format("Provided symbol value '%s' does not match required value '%s' for %s.%s.",
          symbol, tokenType.getSymbol().get(), TokenType.class.getSimpleName(), tokenType.name()));

    this.tokenType = tokenType;
    this.symbol = symbol;
  }

  /**
   * * Generates a {@code String} representation of this object.
   *
   * @return a string representation of this object, not null
   */
  @Override
  @Nonnull
  public String toString() {
    return format("%s{tokenType=%s, symbol=%s}", getClass().getSimpleName(), getTokenType().name(), getSymbol());
  }

  /**
   * Checks if this object is equal to another one.
   *
   * @param other the object to check, null returns false
   * @return true if this is equal to the other object, false otherwise
   */
  @Override
  public boolean equals(@Nullable Object other) {
    if (this == other)
      return true;

    if (other == null || !getClass().equals(other.getClass()))
      return false;

    Token token = (Token) other;

    return Objects.equals(getTokenType(), token.getTokenType())
        && Objects.equals(getSymbol(), token.getSymbol());
  }

  /**
   * A hash code for this object.
   *
   * @return a suitable hash code
   */
  @Override
  public int hashCode() {
    return Objects.hash(getTokenType(), getSymbol());
  }

  /**
   * Gets the type of this token.
   *
   * @return the type of this token, not null
   */
  @Nonnull
  public TokenType getTokenType() {
    return tokenType;
  }

  /**
   * Gets the symbol for this token.
   *
   * @return the symbol for this token, not null
   */
  @Nonnull
  public String getSymbol() {
    return symbol;
  }
}

<|start_filename|>src/main/java/com/lokalized/Range.java<|end_filename|>
/*
 * Copyright 2017 Product Mog LLC.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.lokalized;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.annotation.concurrent.Immutable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;

import static java.lang.String.format;
import static java.util.Objects.requireNonNull;

/**
 * Represents a concrete range of values.
 * <p>
 * This class is not designed to hold large or "infinite" ranges; it is not stream-based.
 * Instead, you might supply a small representative range of values and specify the range is "infinite"
 * if it is understood that the value pattern repeats indefinitely.
 * <p>
 * For example, you might generate an infinite powers-of-ten range with the 4 values {@code 1, 10, 100, 1_000}.
 * <p>
 * Ranges are constructed via static methods.
 * <p>
 * Examples:
 * <ul>
 * <li>{@code Range.ofFiniteValues("a", "b", "c")}</li>
 * <li>{@code Range.ofInfiniteValues(1, 10, 100, 1_000, 10_000)}</li>
 * <li>{@code Range.emptyFiniteRange()}</li>
 * <li>{@code Range.emptyInfiniteRange()}</li>
 * </ul>
 *
 * @author <a href="https://revetkn.com"><NAME></a>
 */
@Immutable
public class Range<T> implements Collection<T> {
  @Nonnull
  private static final Range<?> EMPTY_FINITE_RANGE;
  @Nonnull
  private static final Range<?> EMPTY_INFINITE_RANGE;

  @Nonnull
  private final List<T> values;
  @Nonnull
  private final Boolean infinite;

  static {
    EMPTY_FINITE_RANGE = new Range<>(Collections.emptySet(), false);
    EMPTY_INFINITE_RANGE = new Range<>(Collections.emptySet(), true);
  }

  /**
   * Provides an infinite range for the given values.
   *
   * @param values the values of the range, not null
   * @param <T>    the type of values contained in the range
   * @return an infinite range, not null
   */
  @Nonnull
  public static <T> Range<T> ofInfiniteValues(@Nonnull Collection<T> values) {
    requireNonNull(values);
    return values.size() == 0 ? emptyInfiniteRange() : new Range(values, true);
  }

  /**
   * Provides an infinite range for the given values.
   *
   * @param values the values of the range, may be null
   * @param <T>    the type of values contained in the range
   * @return an infinite range, not null
   */
  @Nonnull
  public static <T> Range<T> ofInfiniteValues(@Nullable T... values) {
    return values == null || values.length == 0 ? emptyInfiniteRange() : new Range(values, true);
  }

  /**
   * Provides a finite range for the given values.
   *
   * @param values the values of the range, not null
   * @param <T>    the type of values contained in the range
   * @return a finite range, not null
   */
  @Nonnull
  public static <T> Range<T> ofFiniteValues(@Nonnull Collection<T> values) {
    requireNonNull(values);
    return values.size() == 0 ? emptyFiniteRange() : new Range(values, false);
  }

  /**
   * Provides a finite range for the given values.
   *
   * @param values the values of the range, may be null
   * @param <T>    the type of values contained in the range
   * @return a finite range, not null
   */
  @Nonnull
  public static <T> Range<T> ofFiniteValues(@Nullable T... values) {
    return values == null || values.length == 0 ? emptyFiniteRange() : new Range(values, false);
  }

  /**
   * Gets the empty finite range.
   *
   * @param <T> the type of values contained in the range
   * @return the empty finite range, not null
   */
  public static <T> Range<T> emptyFiniteRange() {
    return (Range<T>) EMPTY_FINITE_RANGE;
  }

  /**
   * Gets the empty infinite range.
   *
   * @param <T> the type of values contained in the range
   * @return the empty infinite range, not null
   */
  public static <T> Range<T> emptyInfiniteRange() {
    return (Range<T>) EMPTY_INFINITE_RANGE;
  }

  /**
   * Creates a range with the given values and "infinite" flag.
   *
   * @param values   the values that comprise this range, not null
   * @param infinite whether this range is infinite - that is, whether the range's pattern repeats indefinitely, not null
   */
  private Range(@Nonnull Collection<T> values, @Nonnull Boolean infinite) {
    requireNonNull(values);
    requireNonNull(infinite);

    this.values = values.size() == 0 ? Collections.emptyList() : Collections.unmodifiableList(new ArrayList<>(values));
    this.infinite = infinite;
  }

  /**
   * Creates a range with the given values and "infinite" flag.
   *
   * @param values   the values that comprise this range, may be null
   * @param infinite whether this range is infinite - that is, whether the range's pattern repeats indefinitely, not null
   */
  private Range(@Nullable T[] values, @Nonnull Boolean infinite) {
    requireNonNull(values);
    requireNonNull(infinite);

    this.values = values == null ? Collections.emptyList() : Collections.unmodifiableList(Arrays.asList(values));
    this.infinite = infinite;
  }

  /**
   * Returns the number of elements in this range.
   *
   * @return the number of elements in this range
   */
  @Override
  public int size() {
    return getValues().size();
  }

  /**
   * Returns true if this range contains no elements.
   *
   * @return true if this range contains no elements
   */
  @Override
  public boolean isEmpty() {
    return getValues().isEmpty();
  }

  /**
   * Returns true if this range contains the specified value.
   * <p>
   * More formally, returns true if and only if this range contains at least one value v such that {@code (o==null ? v==null : o.equals(v))}.
   *
   * @param value value whose presence in this range is to be tested
   * @return true if this range contains the specified value
   */
  @Override
  public boolean contains(@Nullable Object value) {
    return getValues().contains(value);
  }

  /**
   * Returns an iterator over the values in this range in proper sequence.
   * <p>
   * The returned iterator is <a href="https://docs.oracle.com/javase/8/docs/api/java/util/ArrayList.html#fail-fast" target="_blank">fail-fast</a>.
   *
   * @return an iterator over the values in this range in proper sequence, not null
   */
  @Nonnull
  @Override
  public Iterator<T> iterator() {
    return getValues().iterator();
  }

  /**
   * Returns an array containing all of the values in this range in proper sequence (from first to last value).
   * <p>
   * The returned array will be "safe" in that no references to it are maintained by this range. (In other words, this method must allocate a new array). The caller is thus free to modify the returned array.
   * <p>
   * This method acts as bridge between array-based and collection-based APIs.
   *
   * @return an array containing all of the values in this range in proper sequence, not null
   */
  @Nonnull
  @Override
  public Object[] toArray() {
    return getValues().toArray();
  }

  /**
   * Returns an array containing all of the values in this range in proper sequence (from first to last element); the runtime type of the returned array is that of the specified array. If the range fits in the specified array, it is returned therein. Otherwise, a new array is allocated with the runtime type of the specified array and the size of this range.
   * <p>
   * If the range fits in the specified array with room to spare (i.e., the array has more elements than the range), the element in the array immediately following the end of the collection is set to null. (This is useful in determining the length of the range only if the caller knows that the range does not contain any null elements.)
   *
   * @param a    the array into which the values of the range are to be stored, if it is big enough; otherwise, a new array of the same runtime type is allocated for this purpose. not null
   * @param <T1> the runtime type of the array to contain the collection
   * @return an array containing the values of the range, not null
   */
  @Nonnull
  @Override
  public <T1> T1[] toArray(@Nonnull T1[] a) {
    return getValues().toArray(a);
  }

  /**
   * Guaranteed to throw an exception and leave the range unmodified.
   *
   * @param t the value to add, ignored
   * @return no return value; this method always throws UnsupportedOperationException
   * @throws UnsupportedOperationException always
   * @deprecated Unsupported operation; this type is immutable.
   */
  @Override
  @Deprecated
  public boolean add(@Nullable T t) {
    throw new UnsupportedOperationException();
  }

  /**
   * Guaranteed to throw an exception and leave the range unmodified.
   *
   * @param o the value to remove, ignored
   * @return no return value; this method always throws UnsupportedOperationException
   * @throws UnsupportedOperationException always
   * @deprecated Unsupported operation; this type is immutable.
   */
  @Override
  @Deprecated
  public boolean remove(@Nullable Object o) {
    throw new UnsupportedOperationException();
  }

  /**
   * Returns true if this range contains all of the elements of the specified collection.
   *
   * @param c collection to be checked for containment in this range, not null
   * @return true if this range contains all of the elements of the specified collection
   */
  @Override
  public boolean containsAll(@Nonnull Collection<?> c) {
    requireNonNull(c);
    return getValues().containsAll(c);
  }

  /**
   * Guaranteed to throw an exception and leave the range unmodified.
   *
   * @param c collection containing elements to be added to this range, ignored
   * @return no return value; this method always throws UnsupportedOperationException
   * @throws UnsupportedOperationException always
   * @deprecated Unsupported operation; this type is immutable.
   */
  @Override
  @Deprecated
  public boolean addAll(@Nullable Collection<? extends T> c) {
    throw new UnsupportedOperationException();
  }

  /**
   * Guaranteed to throw an exception and leave the range unmodified.
   *
   * @param c collection containing elements to be removed from this range, ignored
   * @return no return value; this method always throws UnsupportedOperationException
   * @throws UnsupportedOperationException always
   * @deprecated Unsupported operation; this type is immutable.
   */
  @Override
  @Deprecated
  public boolean removeAll(@Nullable Collection<?> c) {
    throw new UnsupportedOperationException();
  }

  /**
   * Guaranteed to throw an exception and leave the range unmodified.
   *
   * @param c collection containing elements to be retained in this range, ignored
   * @return no return value; this method always throws UnsupportedOperationException
   * @throws UnsupportedOperationException always
   * @deprecated Unsupported operation; this type is immutable.
   */
  @Override
  public boolean retainAll(@Nullable Collection<?> c) {
    throw new UnsupportedOperationException();
  }

  /**
   * Guaranteed to throw an exception and leave the range unmodified.
   *
   * @throws UnsupportedOperationException always
   * @deprecated Unsupported operation; this type is immutable.
   */
  @Override
  @Deprecated
  public void clear() {
    throw new UnsupportedOperationException();
  }

  /**
   * Generates a {@code String} representation of this object.
   *
   * @return a string representation of this object, not null
   */
  @Override
  @Nonnull
  public String toString() {
    return format("%s{values=%s, infinite=%s}", getClass().getSimpleName(), getValues().stream()
        .map(value -> value.toString())
        .collect(Collectors.joining(", ")), getInfinite());
  }

  /**
   * Checks if this object is equal to another one.
   *
   * @param other the object to check, null returns false
   * @return true if this is equal to the other object, false otherwise
   */
  @Override
  public boolean equals(@Nullable Object other) {
    if (this == other)
      return true;

    if (other == null || !getClass().equals(other.getClass()))
      return false;

    Range valueRange = (Range) other;

    return Objects.equals(getValues(), valueRange.getValues())
        && Objects.equals(getInfinite(), valueRange.getInfinite());
  }

  /**
   * A hash code for this object.
   *
   * @return a suitable hash code
   */
  @Override
  public int hashCode() {
    return Objects.hash(getValues(), getInfinite());
  }

  /**
   * Gets the values that comprise this range.
   *
   * @return the values that comprise this range, not null
   */
  @Nonnull
  public List<T> getValues() {
    return values;
  }

  /**
   * Gets whether this range is infinite.
   *
   * @return whether this range is infinite - that is, whether the range's pattern repeats indefinitely, not null
   */
  @Nonnull
  public Boolean getInfinite() {
    return infinite;
  }
}

<|start_filename|>src/test/java/com/lokalized/CardinalityTests.java<|end_filename|>
/*
 * Copyright 2017 Product Mog LLC.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.lokalized;

import com.lokalized.Cardinality.CardinalityRangeFamily;
import org.junit.Assert;
import org.junit.Test;

import javax.annotation.concurrent.ThreadSafe;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map.Entry;
import java.util.SortedMap;

import static java.lang.String.format;

/**
 * Exercises {@link Cardinality}.
 *
 * @author <a href="https://revetkn.com"><NAME></a>
 */
@ThreadSafe
public class CardinalityTests {
  @Test
  public void cardinalityForms() {
    Locale usEnglishLocale = Locale.forLanguageTag("en-US");

    Assert.assertEquals(format("Incorrect %s cardinality for 1", usEnglishLocale.toLanguageTag()),
        Cardinality.ONE, Cardinality.forNumber(1, usEnglishLocale));
    Assert.assertEquals(format("Incorrect %s cardinality for 0", usEnglishLocale.toLanguageTag()),
        Cardinality.OTHER, Cardinality.forNumber(0, usEnglishLocale));
    Assert.assertEquals(format("Incorrect %s cardinality for 1.5", usEnglishLocale.toLanguageTag()),
        Cardinality.OTHER, Cardinality.forNumber(1.5, usEnglishLocale));
  }

  @Test
  public void exampleIntegerValues() {
    for (String languageCode : Cardinality.getSupportedLanguageCodes()) {
      Locale locale = Locale.forLanguageTag(languageCode);

      for (Entry<Cardinality, Range<Integer>> entry : Cardinality.exampleIntegerValuesForLocale(locale).entrySet()) {
        Cardinality cardinality = entry.getKey();
        Range<Integer> integers = entry.getValue();

        for (Integer integer : integers) {
          Cardinality calculatedCardinality = Cardinality.forNumber(integer, locale);
          Assert.assertEquals(format("Mismatched '%s' cardinalities for %s. Range was %s",
              locale.toLanguageTag(), integer, integers), cardinality, calculatedCardinality);
        }
      }
    }
  }

  @Test
  public void exampleDecimalValues() {
    for (String languageCode : Cardinality.getSupportedLanguageCodes()) {
      Locale locale = Locale.forLanguageTag(languageCode);

      for (Entry<Cardinality, Range<BigDecimal>> entry : Cardinality.exampleDecimalValuesForLocale(locale).entrySet()) {
        Cardinality cardinality = entry.getKey();
        Range<BigDecimal> bigDecimals = entry.getValue();

        for (BigDecimal bigDecimal : bigDecimals) {
          Cardinality calculatedCardinality = Cardinality.forNumber(bigDecimal, locale);
          Assert.assertEquals(format("Mismatched '%s' cardinalities for %s. Range was %s",
              locale.toLanguageTag(), bigDecimal, bigDecimals), cardinality, calculatedCardinality);
        }
      }
    }
  }

  @Test
  public void ranges() {
    List<CardinalityRange> allCardinalityRanges = new ArrayList<>(Cardinality.values().length * Cardinality.values().length);

    // Cartesian product
    for (Cardinality start : Cardinality.values())
      for (Cardinality end : Cardinality.values())
        allCardinalityRanges.add(CardinalityRange.of(start, end));

    for (String languageCode : Cardinality.getSupportedLanguageCodes()) {
      Locale locale = Locale.forLanguageTag(languageCode);
      CardinalityRangeFamily cardinalityRangeFamily = CardinalityRangeFamily.cardinalityRangeFamilyForLocale(locale).get();
      SortedMap<CardinalityRange, Cardinality> cardinalitiesByCardinalityRange = cardinalityRangeFamily.getCardinalitiesByCardinalityRange();

      for (CardinalityRange cardinalityRange : allCardinalityRanges) {
        Cardinality expectedCardinality = cardinalitiesByCardinalityRange.get(cardinalityRange);

        if (expectedCardinality == null)
          expectedCardinality = Cardinality.OTHER;

        Cardinality actualCardinality = Cardinality.forRange(cardinalityRange.getStart(), cardinalityRange.getEnd(), locale);

        Assert.assertEquals(format("Mismatched cardinality for range %s and locale %s", cardinalityRange, locale.toLanguageTag()),
            expectedCardinality, actualCardinality);
      }
    }
  }
}

<|start_filename|>src/main/java/com/lokalized/LocaleUtils.java<|end_filename|>
/*
 * Copyright 2017 Product Mog LLC.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.lokalized;

import javax.annotation.Nonnull;
import javax.annotation.concurrent.ThreadSafe;
import java.util.Locale;
import java.util.Optional;

import static java.util.Objects.requireNonNull;

/**
 * Collection of utility methods for working with Locales.
 *
 * @author <a href="https://revetkn.com"><NAME></a>
 */
@ThreadSafe
class LocaleUtils {
  private LocaleUtils() {
    // Non-instantiable
  }

  /**
   * Normalizes a locale's language code to its canonical form.
   * <p>
   * See <a target="_blank" href="https://docs.oracle.com/javase/8/docs/api/java/util/Locale.html#getLanguage--">Javadoc for {@code Locale#getLanguage()}</a>.
   * <p>
   * For backwards compatibility, the {@code Locale#convertOldISOCodes()} method ensures {@code Locale#getLanguage()} will always return these now-superseded codes:
   * <p>
   * <ul>
   * <li>{@code iw} for {@code he} (Hebrew)</li>
   * <li>{@code ji} for {@code yi} (Yiddish)</li>
   * <li>{@code in} for {@code id} (Indonesian)</li>
   * </ul>
   * <p>
   * This method ensures we always work with the "modern" versions.
   *
   * @param locale the locale for which the language code is extracted, not null
   * @return the normalized language for the locale (if present), not null
   */
  static Optional<String> normalizedLanguage(@Nonnull Locale locale) {
    requireNonNull(locale);

    String language = locale.getLanguage();

    if (language == null || "".equals(language))
      return Optional.empty();

    if ("iw".equals(language))
      language = "he";
    else if ("ji".equals(language))
      language = "yi";
    else if ("in".equals(language))
      language = "id";

    return Optional.of(language);
  }
}

<|start_filename|>src/test/java/com/lokalized/OrdinalityTests.java<|end_filename|>
/*
 * Copyright 2017 Product Mog LLC.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.lokalized;

import org.junit.Assert;
import org.junit.Test;

import javax.annotation.concurrent.ThreadSafe;
import java.util.Locale;
import java.util.Map;

import static java.lang.String.format;

/**
 * Exercises {@link Cardinality}.
 *
 * @author <a href="https://revetkn.com"><NAME></a>
 */
@ThreadSafe
public class OrdinalityTests {
  @Test
  public void ordinalityForms() {
    Locale usEnglishLocale = Locale.forLanguageTag("en-US");

    Assert.assertEquals(format("Incorrect %s ordinality for 1", usEnglishLocale.toLanguageTag()),
        Ordinality.ONE, Ordinality.forNumber(1, usEnglishLocale));
    Assert.assertEquals(format("Incorrect %s ordinality for 2", usEnglishLocale.toLanguageTag()),
        Ordinality.TWO, Ordinality.forNumber(2, usEnglishLocale));
    Assert.assertEquals(format("Incorrect %s ordinality for 3", usEnglishLocale.toLanguageTag()),
        Ordinality.FEW, Ordinality.forNumber(3, usEnglishLocale));
    Assert.assertEquals(format("Incorrect %s ordinality for 4", usEnglishLocale.toLanguageTag()),
        Ordinality.OTHER, Ordinality.forNumber(4, usEnglishLocale));
    Assert.assertEquals(format("Incorrect %s ordinality for 138", usEnglishLocale.toLanguageTag()),
        Ordinality.OTHER, Ordinality.forNumber(138, usEnglishLocale));
  }

  @Test
  public void exampleIntegerValues() {
    for (String languageCode : Ordinality.getSupportedLanguageCodes()) {
      Locale locale = Locale.forLanguageTag(languageCode);

      for (Map.Entry<Ordinality, Range<Integer>> entry : Ordinality.exampleIntegerValuesForLocale(locale).entrySet()) {
        Ordinality ordinality = entry.getKey();
        Range<Integer> integers = entry.getValue();

        for (Integer integer : integers) {
          Ordinality calculatedOrdinality = Ordinality.forNumber(integer, locale);
          Assert.assertEquals(format("Mismatched '%s' cardinalities for %s. Range was %s",
              locale.toLanguageTag(), integer, integers), ordinality, calculatedOrdinality);
        }
      }
    }
  }
}

<|start_filename|>src/main/java/com/lokalized/Gender.java<|end_filename|>
/*
 * Copyright 2017 Product Mog LLC.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.lokalized;

import javax.annotation.Nonnull;
import java.util.Arrays;
import java.util.Collections;
import java.util.Map;
import java.util.stream.Collectors;

/**
 * Language gender forms.
 *
 * @author <a href="https://revetkn.com"><NAME></a>
 */
public enum Gender implements LanguageForm {
  /**
   * Masculine gender.
   */
  MASCULINE,
  /**
   * Feminine gender.
   */
  FEMININE,
  /**
   * Neutral/unspecified gender.
   */
  NEUTER;

  @Nonnull
  private static final Map<String, Gender> GENDERS_BY_NAME;

  static {
    GENDERS_BY_NAME = Collections.unmodifiableMap(Arrays.stream(
        Gender.values()).collect(Collectors.toMap(gender -> gender.name(), gender -> gender)));
  }

  /**
   * Gets the mapping of gender names to gender values.
   *
   * @return the mapping of gender names to gender values, not null
   */
  @Nonnull
  static Map<String, Gender> getGendersByName() {
    return GENDERS_BY_NAME;
  }
}


<|start_filename|>src/main/java/com/lokalized/Maps.java<|end_filename|>
/*
 * Copyright 2017 Product Mog LLC.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.lokalized;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.annotation.concurrent.Immutable;
import javax.annotation.concurrent.ThreadSafe;
import java.util.Collections;
import java.util.Objects;
import java.util.SortedMap;
import java.util.TreeMap;

import static java.util.Objects.requireNonNull;

/**
 * Collection of utility methods for working with Maps.
 *
 * @author <a href="https://revetkn.com"><NAME></a>
 */
@ThreadSafe
final class Maps {
  private Maps() {
    // Non-instantiable
  }

  /**
   * Creates an immutable sorted map with the given values.
   *
   * @param mapEntries the entries for the map, may be null
   * @param <K>        the type of keys in the map
   * @param <V>        type type of values in the map
   * @return an immutable sorted map, not null
   */
  @Nonnull
  static <K, V> SortedMap<K, V> sortedMap(@Nullable MapEntry<K, V>... mapEntries) {
    if (mapEntries == null || mapEntries.length == 0)
      return Collections.emptySortedMap();

    SortedMap<K, V> sortedMap = new TreeMap<>();

    for (MapEntry<K, V> mapEntry : mapEntries)
      sortedMap.put(mapEntry.getKey(), mapEntry.getValue());

    return Collections.unmodifiableSortedMap(sortedMap);
  }

  /**
   * A representation of a map entry (key-value pair).
   *
   * @param <K> the type of key in the map entry
   * @param <V> the type of value in the map entry
   */
  @Immutable
  static class MapEntry<K, V> {
    @Nonnull
    private final K key;
    @Nonnull
    private final V value;

    /**
     * Provides a map entry with the given key and value.
     *
     * @param key   the key for the map entry, not null
     * @param value the value for the map entry, not null
     * @param <K>
     * @param <V>
     * @return a map entry, not null
     */
    @Nonnull
    public static <K, V> MapEntry<K, V> of(@Nonnull K key, @Nonnull V value) {
      requireNonNull(key);
      requireNonNull(value);

      return new MapEntry<>(key, value);
    }

    /**
     * Creates a map entry with the given key and value.
     *
     * @param key   the key for this map entry, not null
     * @param value the value for this map entry, not null
     */
    private MapEntry(@Nonnull K key, @Nonnull V value) {
      requireNonNull(key);
      requireNonNull(value);

      this.key = key;
      this.value = value;
    }

    /**
     * Generates a {@code String} representation of this object.
     *
     * @return a string representation of this object, not null
     */
    @Override
    @Nonnull
    public String toString() {
      return String.format("%s{key=%s, value=%s}", getClass().getSimpleName(), getKey(), getValue());
    }

    /**
     * Checks if this object is equal to another one.
     *
     * @param other the object to check, null returns false
     * @return true if this is equal to the other object, false otherwise
     */
    @Override
    public boolean equals(@Nullable Object other) {
      if (this == other)
        return true;

      if (other == null || !getClass().equals(other.getClass()))
        return false;

      MapEntry mapEntry = (MapEntry) other;

      return Objects.equals(getKey(), mapEntry.getKey())
          && Objects.equals(getValue(), mapEntry.getValue());
    }

    /**
     * A hash code for this object.
     *
     * @return a suitable hash code
     */
    @Override
    public int hashCode() {
      return Objects.hash(getKey(), getValue());
    }


    /**
     * The key for this map entry.
     *
     * @return the key, not null
     */
    @Nonnull
    public K getKey() {
      return key;
    }

    /**
     * The value for this map entry.
     *
     * @return the value, not null
     */
    @Nonnull
    public V getValue() {
      return value;
    }
  }
}


<|start_filename|>src/main/java/com/lokalized/Cardinality.java<|end_filename|>
/*
 * Copyright 2017 Product Mog LLC.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.lokalized;

import com.lokalized.Maps.MapEntry;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.text.Collator;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.SortedMap;
import java.util.SortedSet;
import java.util.TreeSet;
import java.util.function.Function;
import java.util.stream.Collectors;

import static com.lokalized.NumberUtils.equal;
import static com.lokalized.NumberUtils.inRange;
import static com.lokalized.NumberUtils.inSet;
import static com.lokalized.NumberUtils.notEqual;
import static com.lokalized.NumberUtils.notInRange;
import static com.lokalized.NumberUtils.notInSet;
import static java.lang.String.format;
import static java.util.Objects.requireNonNull;

/**
 * Language plural cardinality forms.
 * <p>
 * For example, English has two: {@code 1 dog, 2 dogs}, while Welsh has many: {@code 0 cŵn, 1 ci, 2 gi, 3 chi, 4 ci}.
 * <p>
 * See the <a href="http://cldr.unicode.org/index/cldr-spec/plural-rules">Unicode Common Locale Data Repository</a>
 * and its <a href="http://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html">Language Plural Rules</a> for details.
 * <p>
 * Per the CLDR:
 * <blockquote>
 * These categories are only mnemonics -- the names don't necessarily imply the exact contents of the category.
 * For example, for both English and French the number 1 has the category one (singular).
 * <p>
 * In English, every other number has a plural form, and is given the category other.
 * French is similar, except that the number 0 also has the category one and not other or zero, because the form of
 * units qualified by 0 is also singular.
 * <p>
 * This is worth emphasizing: A common mistake is to think that "one" is only for only the number 1.
 * Instead, "one" is a category for any number that behaves like 1. So in some languages, for example,
 * one → numbers that end in "1" (like 1, 21, 151) but that don't end in 11 (like "11, 111, 10311).
 * </blockquote>
 *
 * @author <a href="https://revetkn.com"><NAME></a>
 */
public enum Cardinality implements LanguageForm {
  /**
   * Normally the form used with 0, if it is limited to numbers whose integer values end with 0.
   * <p>
   * For example: the Welsh {@code 0 cŵn, 0 cathod} means {@code 0 dogs, 0 cats} in English.
   */
  ZERO,
  /**
   * The form used with 1.
   * <p>
   * For example: the Welsh {@code 1 ci, 1 gath} means {@code 1 dog, 1 cat} in English.
   */
  ONE,
  /**
   * Normally the form used with 2, if it is limited to numbers whose integer values end with 2.
   * <p>
   * For example: the Welsh {@code 2 gi, 2 gath} means {@code 2 dogs, 2 cats} in English.
   */
  TWO,
  /**
   * The form that falls between {@code TWO} and {@code MANY}.
   * <p>
   * For example: the Welsh {@code  3 chi, 3 cath} means {@code 3 dogs, 3 cats} in English.
   */
  FEW,
  /**
   * The form that falls between {@code FEW} and {@code OTHER}.
   * <p>
   * For example: the Welsh {@code 6 chi, 6 chath} means {@code 6 dogs, 6 cats} in English.
   */
  MANY,
  /**
   * General "catchall" form which comprises any cases not handled by the other forms.
   * <p>
   * For example: the Welsh {@code 4 ci, 4 cath} means {@code 4 dogs, 4 cats} in English.
   */
  OTHER;

  @Nonnull
  private static final BigInteger BIG_INTEGER_0;
  @Nonnull
  private static final BigInteger BIG_INTEGER_1;
  @Nonnull
  private static final BigInteger BIG_INTEGER_2;
  @Nonnull
  private static final BigInteger BIG_INTEGER_3;
  @Nonnull
  private static final BigInteger BIG_INTEGER_4;
  @Nonnull
  private static final BigInteger BIG_INTEGER_5;
  @Nonnull
  private static final BigInteger BIG_INTEGER_6;
  @Nonnull
  private static final BigInteger BIG_INTEGER_9;
  @Nonnull
  private static final BigInteger BIG_INTEGER_10;
  @Nonnull
  private static final BigInteger BIG_INTEGER_11;
  @Nonnull
  private static final BigInteger BIG_INTEGER_12;
  @Nonnull
  private static final BigInteger BIG_INTEGER_14;
  @Nonnull
  private static final BigInteger BIG_INTEGER_19;
  @Nonnull
  private static final BigInteger BIG_INTEGER_20;
  @Nonnull
  private static final BigInteger BIG_INTEGER_40;
  @Nonnull
  private static final BigInteger BIG_INTEGER_60;
  @Nonnull
  private static final BigInteger BIG_INTEGER_80;
  @Nonnull
  private static final BigInteger BIG_INTEGER_100;

  @Nonnull
  private static final BigDecimal BIG_DECIMAL_0;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_1;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_2;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_3;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_4;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_5;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_6;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_7;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_9;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_10;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_11;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_12;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_13;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_14;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_19;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_70;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_71;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_72;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_79;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_90;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_91;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_92;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_99;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_100;
  @Nonnull
  private static final BigDecimal BIG_DECIMAL_1_000_000;

  @Nonnull
  static final Map<String, Cardinality> CARDINALITIES_BY_NAME;

  static {
    BIG_INTEGER_0 = BigInteger.ZERO;
    BIG_INTEGER_1 = BigInteger.ONE;
    BIG_INTEGER_2 = BigInteger.valueOf(2);
    BIG_INTEGER_3 = BigInteger.valueOf(3);
    BIG_INTEGER_4 = BigInteger.valueOf(4);
    BIG_INTEGER_5 = BigInteger.valueOf(5);
    BIG_INTEGER_6 = BigInteger.valueOf(6);
    BIG_INTEGER_9 = BigInteger.valueOf(9);
    BIG_INTEGER_10 = BigInteger.TEN;
    BIG_INTEGER_11 = BigInteger.valueOf(11);
    BIG_INTEGER_12 = BigInteger.valueOf(12);
    BIG_INTEGER_14 = BigInteger.valueOf(14);
    BIG_INTEGER_19 = BigInteger.valueOf(19);
    BIG_INTEGER_20 = BigInteger.valueOf(20);
    BIG_INTEGER_40 = BigInteger.valueOf(40);
    BIG_INTEGER_60 = BigInteger.valueOf(60);
    BIG_INTEGER_80 = BigInteger.valueOf(80);
    BIG_INTEGER_100 = BigInteger.valueOf(100);

    BIG_DECIMAL_0 = BigDecimal.ZERO;
    BIG_DECIMAL_1 = BigDecimal.ONE;
    BIG_DECIMAL_2 = BigDecimal.valueOf(2);
    BIG_DECIMAL_3 = BigDecimal.valueOf(3);
    BIG_DECIMAL_4 = BigDecimal.valueOf(4);
    BIG_DECIMAL_5 = BigDecimal.valueOf(5);
    BIG_DECIMAL_6 = BigDecimal.valueOf(6);
    BIG_DECIMAL_7 = BigDecimal.valueOf(7);
    BIG_DECIMAL_9 = BigDecimal.valueOf(9);
    BIG_DECIMAL_10 = BigDecimal.TEN;
    BIG_DECIMAL_11 = BigDecimal.valueOf(11);
    BIG_DECIMAL_12 = BigDecimal.valueOf(12);
    BIG_DECIMAL_13 = BigDecimal.valueOf(13);
    BIG_DECIMAL_14 = BigDecimal.valueOf(14);
    BIG_DECIMAL_19 = BigDecimal.valueOf(19);
    BIG_DECIMAL_70 = BigDecimal.valueOf(70);
    BIG_DECIMAL_71 = BigDecimal.valueOf(71);
    BIG_DECIMAL_72 = BigDecimal.valueOf(72);
    BIG_DECIMAL_79 = BigDecimal.valueOf(79);
    BIG_DECIMAL_90 = BigDecimal.valueOf(90);
    BIG_DECIMAL_91 = BigDecimal.valueOf(91);
    BIG_DECIMAL_92 = BigDecimal.valueOf(92);
    BIG_DECIMAL_99 = BigDecimal.valueOf(99);
    BIG_DECIMAL_100 = BigDecimal.valueOf(100);
    BIG_DECIMAL_1_000_000 = BigDecimal.valueOf(1_000_000);

    CARDINALITIES_BY_NAME = Collections.unmodifiableMap(Arrays.stream(
        Cardinality.values()).collect(Collectors.toMap(cardinality -> cardinality.name(), cardinality -> cardinality)));
  }

  /**
   * Gets an appropriate plural cardinality for the given number and locale.
   * <p>
   * When determining cardinality, the decimal places of {@code number} will be computed and used.
   * Note that if trailing zeroes are important, e.g. {@code 1.00} instead of {@code 1}, you must either specify a {@link BigDecimal} with appropriate
   * scale or supply a non-null {@code visibleDecimalPlaces} value.
   * <p>
   * If you do not provide a {@link BigDecimal} and wish to manually specify the number of visible decimals, use {@link #forNumber(Number, Integer, Locale)} instead.
   * <p>
   * See the <a href="http://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html">CLDR Language Plural Rules</a>
   * for further details.
   *
   * @param number the number that drives pluralization, not null
   * @param locale the locale that drives pluralization, not null
   * @return an appropriate plural cardinality, not null
   * @throws UnsupportedLocaleException if the locale is not supported
   */
  @Nonnull
  public static Cardinality forNumber(@Nonnull Number number, @Nonnull Locale locale) {
    requireNonNull(number);
    requireNonNull(locale);

    return forNumber(number, null, locale);
  }

  /**
   * Gets an appropriate plural cardinality for the given number, visible decimal places, and locale.
   * <p>
   * If {@code visibleDecimalPlaces} is null, then the decimal places of {@code number} will be computed and used.
   * Note that if trailing zeroes are important, e.g. {@code 1.00} instead of {@code 1}, you must either specify a {@link BigDecimal} with appropriate
   * scale or supply a non-null {@code visibleDecimalPlaces} value.
   * <p>
   * See the <a href="http://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html">CLDR Language Plural Rules</a>
   * for further details.
   *
   * @param number               the number that drives pluralization, not null
   * @param visibleDecimalPlaces the number of decimal places that will ultimately be displayed, may be null
   * @param locale               the locale that drives pluralization, not null
   * @return an appropriate plural cardinality, not null
   * @throws UnsupportedLocaleException if the locale is not supported
   */
  @Nonnull
  public static Cardinality forNumber(@Nonnull Number number, @Nullable Integer visibleDecimalPlaces, @Nonnull Locale locale) {
    requireNonNull(number);
    requireNonNull(locale);

    boolean numberIsBigDecimal = number instanceof BigDecimal;
    BigDecimal numberAsBigDecimal = null;

    // If number of visible decimal places is not specified, compute the number of decimal places.
    // If the number is a BigDecimal, then we have access to trailing zeroes.
    // We cannot know the number of trailing zeroes otherwise - onus is on caller to explicitly specify if she cares about this
    if (visibleDecimalPlaces == null && !numberIsBigDecimal) {
      numberAsBigDecimal = NumberUtils.toBigDecimal(number);
      numberAsBigDecimal.setScale(NumberUtils.numberOfDecimalPlaces(number), BigDecimal.ROUND_FLOOR);
    } else if (visibleDecimalPlaces != null && numberIsBigDecimal) {
      numberAsBigDecimal = (BigDecimal) number;
      numberAsBigDecimal.setScale(visibleDecimalPlaces, BigDecimal.ROUND_FLOOR);
    }

    if (numberAsBigDecimal == null)
      numberAsBigDecimal = NumberUtils.toBigDecimal(number);

    Optional<CardinalityFamily> cardinalityFamily = CardinalityFamily.cardinalityFamilyForLocale(locale);

    // TODO: throwing an exception might not be the best solution here...need to think about it
    if (!cardinalityFamily.isPresent())
      throw new UnsupportedLocaleException(locale);

    return cardinalityFamily.get().getCardinalityFunction().apply(numberAsBigDecimal);
  }

  /**
   * Gets an appropriate plural cardinality for the given range (start, end) and locale.
   * <p>
   * For example, a range might be {@code "1-3 hours"}.
   * <p>
   * Note that the cardinality of the end of the range does not necessarily
   * determine the range's cardinality.  In English, we say {@code "0–1 days"} - the value {@code 1} is {@code CARDINALITY_ONE}
   * but the range is {@code CARDINALITY_OTHER}.
   * <p>
   * See the <a href="http://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html">CLDR Language Plural Rules</a>
   * for further details.
   *
   * @param start  the cardinality for the start of the range, not null
   * @param end    the cardinality for the end of the range, not null
   * @param locale the locale that drives pluralization, not null
   * @return an appropriate plural cardinality for the range, not null
   * @throws UnsupportedLocaleException if the locale is not supported
   */
  @Nonnull
  public static Cardinality forRange(@Nonnull Cardinality start, @Nonnull Cardinality end, @Nonnull Locale locale) {
    requireNonNull(start);
    requireNonNull(end);
    requireNonNull(locale);

    Optional<CardinalityRangeFamily> cardinalityRangeFamily = CardinalityRangeFamily.cardinalityRangeFamilyForLocale(locale);

    // TODO: throwing an exception might not be the best solution here...need to think about it
    if (!cardinalityRangeFamily.isPresent())
      throw new UnsupportedLocaleException(locale);

    CardinalityRange cardinalityRange = CardinalityRange.of(start, end);
    Cardinality cardinality = cardinalityRangeFamily.get().getCardinalitiesByCardinalityRange().get(cardinalityRange);

    return cardinality == null ? Cardinality.OTHER : cardinality;
  }

  /**
   * Gets the set of cardinalities supported for the given locale.
   * <p>
   * The empty set will be returned if the locale is not supported.
   * <p>
   * The set's values are sorted by the natural ordering of the {@link Cardinality} enumeration.
   *
   * @param locale the locale to use for lookup, not null
   * @return the cardinalities supported by the given locale, not null
   */
  @Nonnull
  public static SortedSet<Cardinality> supportedCardinalitiesForLocale(@Nonnull Locale locale) {
    requireNonNull(locale);

    Optional<CardinalityFamily> cardinalityFamily = CardinalityFamily.cardinalityFamilyForLocale(locale);
    return cardinalityFamily.isPresent() ? cardinalityFamily.get().getSupportedCardinalities() : Collections.emptySortedSet();
  }

  /**
   * Gets a mapping of cardinalities to example integer values for the given locale.
   * <p>
   * The empty map will be returned if the locale is not supported or if no example values are available.
   * <p>
   * The map's keys are sorted by the natural ordering of the {@link Cardinality} enumeration.
   *
   * @param locale the locale to use for lookup, not null
   * @return a mapping of cardinalities to example integer values, not null
   */
  @Nonnull
  public static SortedMap<Cardinality, Range<Integer>> exampleIntegerValuesForLocale(@Nonnull Locale locale) {
    requireNonNull(locale);

    Optional<CardinalityFamily> cardinalityFamily = CardinalityFamily.cardinalityFamilyForLocale(locale);
    return cardinalityFamily.isPresent() ? cardinalityFamily.get().getExampleIntegerValuesByCardinality() : Collections.emptySortedMap();
  }

  /**
   * Gets a mapping of cardinalities to example decimal values for the given locale.
   * <p>
   * The empty map will be returned if the locale is not supported or if no example values are available.
   * <p>
   * The map's keys are sorted by the natural ordering of the {@link Cardinality} enumeration.
   *
   * @param locale the locale to use for lookup, not null
   * @return a mapping of cardinalities to example decimal values, not null
   */
  @Nonnull
  public static SortedMap<Cardinality, Range<BigDecimal>> exampleDecimalValuesForLocale(@Nonnull Locale locale) {
    requireNonNull(locale);

    Optional<CardinalityFamily> cardinalityFamily = CardinalityFamily.cardinalityFamilyForLocale(locale);
    return cardinalityFamily.isPresent() ? cardinalityFamily.get().getExampleDecimalValuesByCardinality() : Collections.emptySortedMap();
  }

  /**
   * Gets the ISO 639 language codes for which cardinality operations are supported.
   * <p>
   * The set's values are ISO 639 codes and therefore sorted using English collation.
   *
   * @return the ISO 639 language codes for which cardinality operations are supported, not null
   */
  @Nonnull
  public static SortedSet<String> getSupportedLanguageCodes() {
    return CardinalityFamily.getSupportedLanguageCodes();
  }

  /**
   * Gets the mapping of cardinality names to values.
   *
   * @return the mapping of cardinality names to values, not null
   */
  @Nonnull
  static Map<String, Cardinality> getCardinalitiesByName() {
    return CARDINALITIES_BY_NAME;
  }

  /**
   * Plural cardinality forms grouped by language family.
   * <p>
   * Each family has a distinct cardinality calculation rule.
   * <p>
   * For example, Germanic languages {@link CardinalityFamily#FAMILY_3} support two {@link Cardinality} types: {@link Cardinality#ONE} for {@code 1}
   * and {@link Cardinality#OTHER} for all other values.
   * <p>
   * See <a href="http://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html">CLDR Language Plural Rules</a>
   * for more information.
   * <p>
   * Cardinality functions are driven by CLDR data.
   * <p>
   * The expression format as specified by http://www.unicode.org/reports/tr35/tr35-numbers.html#Language_Plural_Rules
   * uses the following notation:
   * <ul>
   * <li>{@code n} absolute value of the source number (integer and decimals).</li>
   * <li>{@code i} integer digits of n.</li>
   * <li>{@code v} number of visible fraction digits in n, with trailing zeros.</li>
   * <li>{@code w} number of visible fraction digits in n, without trailing zeros.</li>
   * <li>{@code f} visible fractional digits in n, with trailing zeros.</li>
   * <li>{@code t} visible fractional digits in n, without trailing zeros.</li>
   * </ul>
   * <p>
   * Some examples follow:
   * <ul>
   * <li>{@code n=1: i=1, v=0, w=0, f=0, t=0}</li>
   * <li>{@code n=1.0: i=1, v=1, w=0, f=0, t=0}</li>
   * <li>{@code n=1.00: i=1, v=2, w=0, f=0, t=0}</li>
   * <li>{@code n=1.3: i=1, v=1, w=1, f=3, t=3}</li>
   * <li>{@code n=1.30: i=1, v=2, w=1, f=30, t=3}</li>
   * <li>{@code n=1.03: i=1, v=2, w=2, f=3, t=3}</li>
   * <li>{@code n=1.230: i=1, v=3, w=2, f=230, t=23}</li>
   * </ul>
   */
  enum CardinalityFamily {
    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Afrikaans (af)</li>
     * <li>Asu (asa)</li>
     * <li>Azeri (az)</li>
     * <li>Bemba (bem)</li>
     * <li>Bena (bez)</li>
     * <li>Bulgarian (bg)</li>
     * <li>Bodo (brx)</li>
     * <li>Chechen (ce)</li>
     * <li>Chiga (cgg)</li>
     * <li>Cherokee (chr)</li>
     * <li>Central Kurdish (ckb)</li>
     * <li>Divehi (dv)</li>
     * <li>Ewe (ee)</li>
     * <li>Greek (el)</li>
     * <li>Esperanto (eo)</li>
     * <li>Spanish (es)</li>
     * <li>Basque (eu)</li>
     * <li>Faroese (fo)</li>
     * <li>Friulian (fur)</li>
     * <li>Swiss German (gsw)</li>
     * <li>Hausa (ha)</li>
     * <li>Hawaiian (haw)</li>
     * <li>Hungarian (hu)</li>
     * <li>Ngomba (jgo)</li>
     * <li>Machame (jmc)</li>
     * <li>Georgian (ka)</li>
     * <li>Jju (kaj)</li>
     * <li>Tyap (kcg)</li>
     * <li>Kazakh (kk)</li>
     * <li>Kako (kkj)</li>
     * <li>Greenlandic (kl)</li>
     * <li>Kashmiri (ks)</li>
     * <li>Shambala (ksb)</li>
     * <li>Kurdish (ku)</li>
     * <li>Kirghiz (ky)</li>
     * <li>Luxembourgish (lb)</li>
     * <li>Ganda (lg)</li>
     * <li>Masai (mas)</li>
     * <li>Metaʼ (mgo)</li>
     * <li>Malayalam (ml)</li>
     * <li>Mongolian (mn)</li>
     * <li>Nahuatl (nah)</li>
     * <li>Norwegian Bokmål (nb)</li>
     * <li>North Ndebele (nd)</li>
     * <li>Nepali (ne)</li>
     * <li>Norwegian Nynorsk (nn)</li>
     * <li>Ngiemboon (nnh)</li>
     * <li>Norwegian (no)</li>
     * <li>South Ndebele (nr)</li>
     * <li>Nyanja (ny)</li>
     * <li>Nyankole (nyn)</li>
     * <li>Oromo (om)</li>
     * <li>Odia (or)</li>
     * <li>Ossetian (os)</li>
     * <li>Papiamento (pap)</li>
     * <li>Pushto (ps)</li>
     * <li>Romansh (rm)</li>
     * <li>Rombo (rof)</li>
     * <li>Rwa (rwk)</li>
     * <li>Samburu (saq)</li>
     * <li>Southern Kurdish (sdh)</li>
     * <li>Sena (seh)</li>
     * <li>Shona (sn)</li>
     * <li>Somali (so)</li>
     * <li>Albanian (sq)</li>
     * <li>Swati (ss)</li>
     * <li>Saho (ssy)</li>
     * <li>Southern Sotho (st)</li>
     * <li>Syriac (syr)</li>
     * <li>Tamil (ta)</li>
     * <li>Telugu (te)</li>
     * <li>Teso (teo)</li>
     * <li>Tigre (tig)</li>
     * <li>Turkmen (tk)</li>
     * <li>Tswana (tn)</li>
     * <li>Turkish (tr)</li>
     * <li>Tsonga (ts)</li>
     * <li>Uighur (ug)</li>
     * <li>Uzbek (uz)</li>
     * <li>Venda (ve)</li>
     * <li>Volapük (vo)</li>
     * <li>Vunjo (vun)</li>
     * <li>Walser (wae)</li>
     * <li>Xhosa (xh)</li>
     * <li>Soga (xog)</li>
     * </ul>
     */
    FAMILY_1(
        (n) -> {
          // n = 1
          if (equal(n, BIG_DECIMAL_1))
            return ONE;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofFiniteValues(1)),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 100, 1000, 10000, 100000, 1000000))
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(new BigDecimal("0.0"), new BigDecimal("0.1"), new BigDecimal("0.2"), new BigDecimal("0.3"), new BigDecimal("0.4"), new BigDecimal("0.5"), new BigDecimal("0.6"), new BigDecimal("0.7"), new BigDecimal("0.8"), new BigDecimal("0.9"), new BigDecimal("1.1"), new BigDecimal("1.2"), new BigDecimal("1.3"), new BigDecimal("1.4"), new BigDecimal("1.5"), new BigDecimal("1.6")))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Bambara (bm)</li>
     * <li>Tibetan (bo)</li>
     * <li>Dzongkha (dz)</li>
     * <li>Indonesian (id)</li>
     * <li>Igbo (ig)</li>
     * <li>Sichuan Yi (ii)</li>
     * <li>Japanese (ja)</li>
     * <li>Lojban (jbo)</li>
     * <li>Javanese (jv)</li>
     * <li>Javanese (jw)</li>
     * <li>Makonde (kde)</li>
     * <li>Kabuverdianu (kea)</li>
     * <li>Khmer (km)</li>
     * <li>Korean (ko)</li>
     * <li>Lakota (lkt)</li>
     * <li>Lao (lo)</li>
     * <li>Malay (ms)</li>
     * <li>Burmese (my)</li>
     * <li>N’Ko (nqo)</li>
     * <li>Root (root)</li>
     * <li>Sakha (sah)</li>
     * <li>Koyraboro Senni (ses)</li>
     * <li>Sango (sg)</li>
     * <li>Thai (th)</li>
     * <li>Tongan (to)</li>
     * <li>Vietnamese (vi)</li>
     * <li>Wolof (wo)</li>
     * <li>Yoruba (yo)</li>
     * <li>Cantonese (yue)</li>
     * <li>Mandarin Chinese (zh)</li>
     * </ul>
     */
    FAMILY_2(
        (n) -> {
          // No cardinality rules for this family
          return OTHER;
        },
        Sets.sortedSet(
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 100, 1000, 10000, 100000, 1000000))
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(new BigDecimal("0.0"), new BigDecimal("0.1"), new BigDecimal("0.2"), new BigDecimal("0.3"), new BigDecimal("0.4"), new BigDecimal("0.5"), new BigDecimal("0.6"), new BigDecimal("0.7"), new BigDecimal("0.8"), new BigDecimal("0.9"), new BigDecimal("1.0"), new BigDecimal("1.1"), new BigDecimal("1.2"), new BigDecimal("1.3"), new BigDecimal("1.4"), new BigDecimal("1.5")))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Asturian (ast)</li>
     * <li>Catalan (ca)</li>
     * <li>German (de)</li>
     * <li>English (en)</li>
     * <li>Estonian (et)</li>
     * <li>Finnish (fi)</li>
     * <li>Western Frisian (fy)</li>
     * <li>Galician (gl)</li>
     * <li>Italian (it)</li>
     * <li>Dutch (nl)</li>
     * <li>Swedish (sv)</li>
     * <li>Swahili (sw)</li>
     * <li>Urdu (ur)</li>
     * <li>Yiddish (yi)</li>
     * </ul>
     */
    FAMILY_3(
        (n) -> {
          BigInteger i = NumberUtils.integerComponent(n);
          int v = NumberUtils.numberOfDecimalPlaces(n);

          // i = 1 and v = 0
          if (equal(i, BIG_INTEGER_1) && v == 0)
            return ONE;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofFiniteValues(1)),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 100, 1000, 10000, 100000, 1000000))
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(new BigDecimal("0.0"), new BigDecimal("0.1"), new BigDecimal("0.2"), new BigDecimal("0.3"), new BigDecimal("0.4"), new BigDecimal("0.5"), new BigDecimal("0.6"), new BigDecimal("0.7"), new BigDecimal("0.8"), new BigDecimal("0.9"), new BigDecimal("1.0"), new BigDecimal("1.1"), new BigDecimal("1.2"), new BigDecimal("1.3"), new BigDecimal("1.4"), new BigDecimal("1.5")))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Akan (ak)</li>
     * <li>Bihari (bh)</li>
     * <li>Gun (guw)</li>
     * <li>Lingala (ln)</li>
     * <li>Malagasy (mg)</li>
     * <li>Northern Sotho (nso)</li>
     * <li>Punjabi (pa)</li>
     * <li>Tigrinya (ti)</li>
     * <li>Walloon (wa)</li>
     * </ul>
     */
    FAMILY_4(
        (n) -> {
          // n = 0..1
          if (inRange(n, BIG_DECIMAL_0, BIG_DECIMAL_1))
            return ONE;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofFiniteValues(0, 1)),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 100, 1000, 10000, 100000, 1000000))
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(new BigDecimal("0.1"), new BigDecimal("0.2"), new BigDecimal("0.3"), new BigDecimal("0.4"), new BigDecimal("0.5"), new BigDecimal("0.6"), new BigDecimal("0.7"), new BigDecimal("0.8"), new BigDecimal("0.9"), new BigDecimal("1.1"), new BigDecimal("1.2"), new BigDecimal("1.3"), new BigDecimal("1.4"), new BigDecimal("1.5"), new BigDecimal("1.6"), new BigDecimal("1.7")))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Amharic (am)</li>
     * <li>Assamese (as)</li>
     * <li>Bangla (bn)</li>
     * <li>Persian (fa)</li>
     * <li>Gujarati (gu)</li>
     * <li>Hindi (hi)</li>
     * <li>Kannada (kn)</li>
     * <li>Marathi (mr)</li>
     * <li>Zulu (zu)</li>
     * </ul>
     */
    FAMILY_5(
        (n) -> {
          // i = 0 or n = 1
          BigInteger i = NumberUtils.integerComponent(n);

          if (equal(i, BIG_INTEGER_0) || equal(n, BIG_DECIMAL_1))
            return ONE;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofFiniteValues(0, 1)),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 100, 1000, 10000, 100000, 1000000))
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofFiniteValues(new BigDecimal("0.0"), new BigDecimal("0.01"), new BigDecimal("0.02"), new BigDecimal("0.03"), new BigDecimal("0.04"), new BigDecimal("0.1"), new BigDecimal("0.2"), new BigDecimal("0.3"), new BigDecimal("0.4"), new BigDecimal("0.5"), new BigDecimal("0.6"), new BigDecimal("0.7"), new BigDecimal("0.8"), new BigDecimal("0.9"), new BigDecimal("1.0"))),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(new BigDecimal("1.1"), new BigDecimal("1.2"), new BigDecimal("1.3"), new BigDecimal("1.4"), new BigDecimal("1.5"), new BigDecimal("1.6"), new BigDecimal("1.7"), new BigDecimal("1.8"), new BigDecimal("1.9"), new BigDecimal("2.0"), new BigDecimal("2.1"), new BigDecimal("2.2"), new BigDecimal("2.3"), new BigDecimal("2.4"), new BigDecimal("2.5"), new BigDecimal("2.6")))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Inuktitut (iu)</li>
     * <li>Cornish (kw)</li>
     * <li>Nama (naq)</li>
     * <li>Northern Sami (se)</li>
     * <li>Southern Sami (sma)</li>
     * <li>Sami (smi)</li>
     * <li>Lule Sami (smj)</li>
     * <li>Inari Sami (smn)</li>
     * <li>Skolt Sami (sms)</li>
     * </ul>
     */
    FAMILY_6(
        (n) -> {
          // n = 1
          if (equal(n, BIG_DECIMAL_1))
            return ONE;
          // n = 2
          if (equal(n, BIG_DECIMAL_2))
            return TWO;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            TWO,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofFiniteValues(1)),
            MapEntry.of(Cardinality.TWO, Range.ofFiniteValues(2)),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(0, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 100, 1000, 10000, 100000, 1000000))
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(new BigDecimal("0.0"), new BigDecimal("0.1"), new BigDecimal("0.2"), new BigDecimal("0.3"), new BigDecimal("0.4"), new BigDecimal("0.5"), new BigDecimal("0.6"), new BigDecimal("0.7"), new BigDecimal("0.8"), new BigDecimal("0.9"), new BigDecimal("1.1"), new BigDecimal("1.2"), new BigDecimal("1.3"), new BigDecimal("1.4"), new BigDecimal("1.5"), new BigDecimal("1.6")))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Bosnian (bs)</li>
     * <li>Croatian (hr)</li>
     * <li>Serbo-Croatian (sh)</li>
     * <li>Serbian (sr)</li>
     * </ul>
     */
    FAMILY_7(
        (n) -> {
          int v = NumberUtils.numberOfDecimalPlaces(n);
          BigInteger i = NumberUtils.integerComponent(n);
          BigInteger f = NumberUtils.fractionalComponent(n);

          // v = 0 and i % 10 = 1 and i % 100 != 11 or f % 10 = 1 and f % 100 != 11
          if ((v == 0 && equal(i.mod(BIG_INTEGER_10), BIG_INTEGER_1) && notEqual(i.mod(BIG_INTEGER_100), BIG_INTEGER_11))
              || (equal(f.mod(BIG_INTEGER_10), BIG_INTEGER_1) && notEqual(f.mod(BIG_INTEGER_100), BIG_INTEGER_11)))
            return ONE;
          // v = 0 and i % 10 = 2..4 and i % 100 != 12..14 or f % 10 = 2..4 and f % 100 != 12..14
          if ((v == 0
              && inRange(i.mod(BIG_INTEGER_10), BIG_INTEGER_2, BIG_INTEGER_4)
              && notInRange(i.mod(BIG_INTEGER_100), BIG_INTEGER_12, BIG_INTEGER_14))
              ||
              (inRange(f.mod(BIG_INTEGER_10), BIG_INTEGER_2, BIG_INTEGER_4)
                  && notInRange(f.mod(BIG_INTEGER_100), BIG_INTEGER_12, BIG_INTEGER_14)))
            return FEW;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            FEW,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofInfiniteValues(1, 21, 31, 41, 51, 61, 71, 81, 101, 1001)),
            MapEntry.of(Cardinality.FEW, Range.ofInfiniteValues(2, 3, 4, 22, 23, 24, 32, 33, 34, 42, 43, 44, 52, 53, 54, 62, 102, 1002)),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(0, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 100, 1000, 10000, 100000, 1000000))
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofInfiniteValues(new BigDecimal("0.1"), new BigDecimal("1.1"), new BigDecimal("2.1"), new BigDecimal("3.1"), new BigDecimal("4.1"), new BigDecimal("5.1"), new BigDecimal("6.1"), new BigDecimal("7.1"), new BigDecimal("10.1"), new BigDecimal("100.1"), new BigDecimal("1000.1"))),
            MapEntry.of(Cardinality.FEW, Range.ofInfiniteValues(new BigDecimal("0.2"), new BigDecimal("0.3"), new BigDecimal("0.4"), new BigDecimal("1.2"), new BigDecimal("1.3"), new BigDecimal("1.4"), new BigDecimal("2.2"), new BigDecimal("2.3"), new BigDecimal("2.4"), new BigDecimal("3.2"), new BigDecimal("3.3"), new BigDecimal("3.4"), new BigDecimal("4.2"), new BigDecimal("4.3"), new BigDecimal("4.4"), new BigDecimal("5.2"), new BigDecimal("10.2"), new BigDecimal("100.2"), new BigDecimal("1000.2"))),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(new BigDecimal("0.5"), new BigDecimal("0.6"), new BigDecimal("0.7"), new BigDecimal("0.8"), new BigDecimal("0.9"), new BigDecimal("1.0"), new BigDecimal("1.5"), new BigDecimal("1.6"), new BigDecimal("1.7"), new BigDecimal("1.8"), new BigDecimal("1.9"), new BigDecimal("2.0"), new BigDecimal("2.5"), new BigDecimal("2.6"), new BigDecimal("2.7")))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Fulah (ff)</li>
     * <li>French (fr)</li>
     * <li>Armenian (hy)</li>
     * <li>Kabyle (kab)</li>
     * </ul>
     */
    FAMILY_8(
        (n) -> {
          BigInteger i = NumberUtils.integerComponent(n);

          // i = 0,1
          if (inSet(i, BIG_INTEGER_0, BIG_INTEGER_1))
            return ONE;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofFiniteValues(0, 1)),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 100, 1000, 10000, 100000, 1000000))
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofFiniteValues(new BigDecimal("0.0"), new BigDecimal("0.1"), new BigDecimal("0.2"), new BigDecimal("0.3"), new BigDecimal("0.4"), new BigDecimal("0.5"), new BigDecimal("0.6"), new BigDecimal("0.7"), new BigDecimal("0.8"), new BigDecimal("0.9"), new BigDecimal("1.0"), new BigDecimal("1.1"), new BigDecimal("1.2"), new BigDecimal("1.3"), new BigDecimal("1.4"), new BigDecimal("1.5"))),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(new BigDecimal("2.0"), new BigDecimal("2.1"), new BigDecimal("2.2"), new BigDecimal("2.3"), new BigDecimal("2.4"), new BigDecimal("2.5"), new BigDecimal("2.6"), new BigDecimal("2.7"), new BigDecimal("2.8"), new BigDecimal("2.9"), new BigDecimal("3.0"), new BigDecimal("3.1"), new BigDecimal("3.2"), new BigDecimal("3.3"), new BigDecimal("3.4"), new BigDecimal("3.5")))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Arabic (ar)</li>
     * <li>Najdi Arabic (ars)</li>
     * </ul>
     */
    FAMILY_9(
        (n) -> {
          // n = 0
          if (equal(n, BIG_DECIMAL_0))
            return ZERO;
          // n = 1
          if (equal(n, BIG_DECIMAL_1))
            return ONE;
          // n = 2
          if (equal(n, BIG_DECIMAL_2))
            return TWO;
          // n % 100 = 3..10
          if (inRange(n.remainder(BIG_DECIMAL_100), BIG_DECIMAL_3, BIG_DECIMAL_10))
            return FEW;
          // n % 100 = 11..99
          if (inRange(n.remainder(BIG_DECIMAL_100), BIG_DECIMAL_11, BIG_DECIMAL_99))
            return MANY;

          return OTHER;
        },
        Sets.sortedSet(
            ZERO,
            ONE,
            TWO,
            FEW,
            MANY,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ZERO, Range.ofFiniteValues(0)),
            MapEntry.of(Cardinality.ONE, Range.ofFiniteValues(1)),
            MapEntry.of(Cardinality.TWO, Range.ofFiniteValues(2)),
            MapEntry.of(Cardinality.FEW, Range.ofInfiniteValues(3, 4, 5, 6, 7, 8, 9, 10, 103, 104, 105, 106, 107, 108, 109, 110, 1003)),
            MapEntry.of(Cardinality.MANY, Range.ofInfiniteValues(11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 111, 1011)),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(100, 101, 102, 200, 201, 202, 300, 301, 302, 400, 401, 402, 500, 501, 502, 600, 1000, 10000, 100000, 1000000))
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(new BigDecimal("0.1"), new BigDecimal("0.2"), new BigDecimal("0.3"), new BigDecimal("0.4"), new BigDecimal("0.5"), new BigDecimal("0.6"), new BigDecimal("0.7"), new BigDecimal("0.8"), new BigDecimal("0.9"), new BigDecimal("1.1"), new BigDecimal("1.2"), new BigDecimal("1.3"), new BigDecimal("1.4"), new BigDecimal("1.5"), new BigDecimal("1.6"), new BigDecimal("1.7"), new BigDecimal("10.1")))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Czech (cs)</li>
     * <li>Slovak (sk)</li>
     * </ul>
     */
    FAMILY_10(
        (n) -> {
          int v = NumberUtils.numberOfDecimalPlaces(n);
          BigInteger i = NumberUtils.integerComponent(n);

          // i = 1 and v = 0
          if (equal(i, BIG_INTEGER_1) && v == 0)
            return ONE;
          // i = 2..4 and v = 0
          if (inRange(i, BIG_INTEGER_2, BIG_INTEGER_4) && v == 0)
            return FEW;
          // v != 0
          if (v != 0)
            return MANY;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            FEW,
            MANY,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofFiniteValues(1)),
            MapEntry.of(Cardinality.FEW, Range.ofFiniteValues(2, 3, 4)),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(0, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 100, 1000, 10000, 100000, 1000000))
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.MANY, Range.ofInfiniteValues(new BigDecimal("0.0"), new BigDecimal("0.1"), new BigDecimal("0.2"), new BigDecimal("0.3"), new BigDecimal("0.4"), new BigDecimal("0.5"), new BigDecimal("0.6"), new BigDecimal("0.7"), new BigDecimal("0.8"), new BigDecimal("0.9"), new BigDecimal("1.0"), new BigDecimal("1.1"), new BigDecimal("1.2"), new BigDecimal("1.3"), new BigDecimal("1.4"), new BigDecimal("1.5")))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Lower Sorbian (dsb)</li>
     * <li>Upper Sorbian (hsb)</li>
     * </ul>
     */
    FAMILY_11(
        (n) -> {
          int v = NumberUtils.numberOfDecimalPlaces(n);
          BigInteger i = NumberUtils.integerComponent(n);
          BigInteger f = NumberUtils.fractionalComponent(n);

          // v = 0 and i % 100 = 1 or f % 100 = 1
          if ((v == 0 && equal(i.mod(BIG_INTEGER_100), BIG_INTEGER_1))
              || (equal(f.mod(BIG_INTEGER_100), BIG_INTEGER_1)))
            return ONE;
          // v = 0 and i % 100 = 2 or f % 100 = 2
          if ((v == 0 && equal(i.mod(BIG_INTEGER_100), BIG_INTEGER_2))
              || equal(f.mod(BIG_INTEGER_100), BIG_INTEGER_2))
            return TWO;
          // v = 0 and i % 100 = 3..4 or f % 100 = 3..4
          if ((v == 0 && inRange(i.mod(BIG_INTEGER_100), BIG_INTEGER_3, BIG_INTEGER_4))
              || inRange(f.mod(BIG_INTEGER_100), BIG_INTEGER_3, BIG_INTEGER_4))
            return FEW;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            TWO,
            FEW,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofInfiniteValues(1, 101, 201, 301, 401, 501, 601, 701, 1001)),
            MapEntry.of(Cardinality.TWO, Range.ofInfiniteValues(2, 102, 202, 302, 402, 502, 602, 702, 1002)),
            MapEntry.of(Cardinality.FEW, Range.ofInfiniteValues(3, 4, 103, 104, 203, 204, 303, 304, 403, 404, 503, 504, 603, 604, 703, 704, 1003)),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(0, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 100, 1000, 10000, 100000, 1000000))
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofInfiniteValues(new BigDecimal("0.1"), new BigDecimal("1.1"), new BigDecimal("2.1"), new BigDecimal("3.1"), new BigDecimal("4.1"), new BigDecimal("5.1"), new BigDecimal("6.1"), new BigDecimal("7.1"), new BigDecimal("10.1"), new BigDecimal("100.1"), new BigDecimal("1000.1"))),
            MapEntry.of(Cardinality.TWO, Range.ofInfiniteValues(new BigDecimal("0.2"), new BigDecimal("1.2"), new BigDecimal("2.2"), new BigDecimal("3.2"), new BigDecimal("4.2"), new BigDecimal("5.2"), new BigDecimal("6.2"), new BigDecimal("7.2"), new BigDecimal("10.2"), new BigDecimal("100.2"), new BigDecimal("1000.2"))),
            MapEntry.of(Cardinality.FEW, Range.ofInfiniteValues(new BigDecimal("0.3"), new BigDecimal("0.4"), new BigDecimal("1.3"), new BigDecimal("1.4"), new BigDecimal("2.3"), new BigDecimal("2.4"), new BigDecimal("3.3"), new BigDecimal("3.4"), new BigDecimal("4.3"), new BigDecimal("4.4"), new BigDecimal("5.3"), new BigDecimal("5.4"), new BigDecimal("6.3"), new BigDecimal("6.4"), new BigDecimal("7.3"), new BigDecimal("7.4"), new BigDecimal("10.3"), new BigDecimal("100.3"), new BigDecimal("1000.3"))),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(new BigDecimal("0.5"), new BigDecimal("0.6"), new BigDecimal("0.7"), new BigDecimal("0.8"), new BigDecimal("0.9"), new BigDecimal("1.0"), new BigDecimal("1.5"), new BigDecimal("1.6"), new BigDecimal("1.7"), new BigDecimal("1.8"), new BigDecimal("1.9"), new BigDecimal("2.0"), new BigDecimal("2.5"), new BigDecimal("2.6"), new BigDecimal("2.7")))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Filipino (fil)</li>
     * <li>Tagalog (tl)</li>
     * </ul>
     */
    FAMILY_12(
        (n) -> {
          int v = NumberUtils.numberOfDecimalPlaces(n);
          BigInteger i = NumberUtils.integerComponent(n);
          BigInteger f = NumberUtils.fractionalComponent(n);

          // v = 0 and i = 1,2,3 or v = 0 and i % 10 != 4,6,9 or v != 0 and f % 10 != 4,6,9
          if ((v == 0 && inSet(i, BIG_INTEGER_1, BIG_INTEGER_2, BIG_INTEGER_3))
              || (v == 0 && notInSet(i.mod(BIG_INTEGER_10), BIG_INTEGER_4, BIG_INTEGER_6, BIG_INTEGER_9))
              || (v != 0 && notInSet(f.mod(BIG_INTEGER_10), BIG_INTEGER_4, BIG_INTEGER_6, BIG_INTEGER_9)))
            return ONE;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofInfiniteValues(0, 1, 2, 3, 5, 7, 8, 10, 11, 12, 13, 15, 17, 18, 20, 21, 100, 1000, 10000, 100000, 1000000)),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(4, 6, 9, 14, 16, 19, 24, 26, 104, 1004))
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofInfiniteValues(new BigDecimal("0.0"), new BigDecimal("0.1"), new BigDecimal("0.2"), new BigDecimal("0.3"), new BigDecimal("0.5"), new BigDecimal("0.7"), new BigDecimal("0.8"), new BigDecimal("1.0"), new BigDecimal("1.1"), new BigDecimal("1.2"), new BigDecimal("1.3"), new BigDecimal("1.5"), new BigDecimal("1.7"), new BigDecimal("1.8"), new BigDecimal("2.1"))),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(new BigDecimal("0.4"), new BigDecimal("0.6"), new BigDecimal("0.9"), new BigDecimal("1.4"), new BigDecimal("1.6"), new BigDecimal("1.9"), new BigDecimal("2.4"), new BigDecimal("2.6"), new BigDecimal("10.4"), new BigDecimal("100.4"), new BigDecimal("1000.4")))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Latvian (lv)</li>
     * <li>Prussian (prg)</li>
     * </ul>
     */
    FAMILY_13(
        (n) -> {
          int v = NumberUtils.numberOfDecimalPlaces(n);
          BigInteger f = NumberUtils.fractionalComponent(n);

          // n % 10 = 0 or n % 100 = 11..19 or v = 2 and f % 100 = 11..19
          if (equal(n.remainder(BIG_DECIMAL_10), BIG_DECIMAL_0)
              || inRange(n.remainder(BIG_DECIMAL_100), BIG_DECIMAL_11, BIG_DECIMAL_19)
              || (v == 2 && inRange(f.mod(BIG_INTEGER_100), BIG_INTEGER_11, BIG_INTEGER_19)))
            return ZERO;
          // n % 10 = 1 and n % 100 != 11 or v = 2 and f % 10 = 1 and f % 100 != 11 or v != 2 and f % 10 = 1
          if ((equal(n.remainder(BIG_DECIMAL_10), BIG_DECIMAL_1) && notEqual(n.remainder(BIG_DECIMAL_100), BIG_DECIMAL_11))
              || (v == 2 && equal(f.mod(BIG_INTEGER_10), BIG_INTEGER_1) && notEqual(f.mod(BIG_INTEGER_100), BIG_INTEGER_11))
              || (v != 2 && equal(f.mod(BIG_INTEGER_10), BIG_INTEGER_1)))
            return ONE;

          return OTHER;
        },
        Sets.sortedSet(
            ZERO,
            ONE,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ZERO, Range.ofInfiniteValues(0, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 30, 40, 50, 60, 100, 1000, 10000, 100000, 1000000)),
            MapEntry.of(Cardinality.ONE, Range.ofInfiniteValues(1, 21, 31, 41, 51, 61, 71, 81, 101, 1001)),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(2, 3, 4, 5, 6, 7, 8, 9, 22, 23, 24, 25, 26, 27, 28, 29, 102, 1002))
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofInfiniteValues(new BigDecimal("0.1"), new BigDecimal("1.1"), new BigDecimal("2.1"), new BigDecimal("3.1"), new BigDecimal("4.1"), new BigDecimal("5.1"), new BigDecimal("6.1"), new BigDecimal("7.1"), new BigDecimal("10.1"), new BigDecimal("100.1"), new BigDecimal("1000.1"))),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(new BigDecimal("0.2"), new BigDecimal("0.3"), new BigDecimal("0.4"), new BigDecimal("0.5"), new BigDecimal("0.6"), new BigDecimal("0.7"), new BigDecimal("0.8"), new BigDecimal("0.9"), new BigDecimal("1.2"), new BigDecimal("1.3"), new BigDecimal("1.4"), new BigDecimal("1.5"), new BigDecimal("1.6"), new BigDecimal("1.7"), new BigDecimal("1.8"), new BigDecimal("1.9"), new BigDecimal("10.2"), new BigDecimal("100.2"), new BigDecimal("1000.2")))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Moldovan (mo)</li>
     * <li>Romanian (ro)</li>
     * </ul>
     */
    FAMILY_14(
        (n) -> {
          int v = NumberUtils.numberOfDecimalPlaces(n);
          BigInteger i = NumberUtils.integerComponent(n);

          // i = 1 and v = 0
          if (equal(i, BIG_INTEGER_1) && v == 0)
            return ONE;
          // v != 0 or n = 0 or n != 1 and n % 100 = 1..19
          if (v != 0
              || equal(n, BIG_DECIMAL_0)
              || (notEqual(n, BIG_DECIMAL_1) && inRange(n.remainder(BIG_DECIMAL_100), BIG_DECIMAL_1, BIG_DECIMAL_19)))
            return FEW;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            FEW,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofFiniteValues(1)),
            MapEntry.of(Cardinality.FEW, Range.ofInfiniteValues(0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 101, 1001)),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 100, 1000, 10000, 100000, 1000000))
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.FEW, Range.ofInfiniteValues(new BigDecimal("0.0"), new BigDecimal("0.1"), new BigDecimal("0.2"), new BigDecimal("0.3"), new BigDecimal("0.4"), new BigDecimal("0.5"), new BigDecimal("0.6"), new BigDecimal("0.7"), new BigDecimal("0.8"), new BigDecimal("0.9"), new BigDecimal("1.0"), new BigDecimal("1.1"), new BigDecimal("1.2"), new BigDecimal("1.3"), new BigDecimal("1.4"), new BigDecimal("1.5")))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Russian (ru)</li>
     * <li>Ukrainian (uk)</li>
     * </ul>
     */
    FAMILY_15(
        (n) -> {
          int v = NumberUtils.numberOfDecimalPlaces(n);
          BigInteger i = NumberUtils.integerComponent(n);

          // v = 0 and i % 10 = 1 and i % 100 != 11
          if (v == 0 && equal(i.mod(BIG_INTEGER_10), BIG_INTEGER_1) && notEqual(i.mod(BIG_INTEGER_100), BIG_INTEGER_11))
            return ONE;
          // v = 0 and i % 10 = 2..4 and i % 100 != 12..14
          if (v == 0
              && inRange(i.mod(BIG_INTEGER_10), BIG_INTEGER_2, BIG_INTEGER_4)
              && notInRange(i.mod(BIG_INTEGER_100), BIG_INTEGER_12, BIG_INTEGER_14))
            return FEW;
          // v = 0 and i % 10 = 0 or v = 0 and i % 10 = 5..9 or v = 0 and i % 100 = 11..14
          if ((v == 0 && equal(i.mod(BIG_INTEGER_10), BIG_INTEGER_0))
              || (v == 0 && inRange(i.mod(BIG_INTEGER_10), BIG_INTEGER_5, BIG_INTEGER_9))
              || (v == 0 && inRange(i.mod(BIG_INTEGER_100), BIG_INTEGER_11, BIG_INTEGER_14)))
            return MANY;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            FEW,
            MANY,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofInfiniteValues(1, 21, 31, 41, 51, 61, 71, 81, 101, 1001)),
            MapEntry.of(Cardinality.FEW, Range.ofInfiniteValues(2, 3, 4, 22, 23, 24, 32, 33, 34, 42, 43, 44, 52, 53, 54, 62, 102, 1002)),
            MapEntry.of(Cardinality.MANY, Range.ofInfiniteValues(0, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 100, 1000, 10000, 100000, 1000000))
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(new BigDecimal("0.0"), new BigDecimal("0.1"), new BigDecimal("0.2"), new BigDecimal("0.3"), new BigDecimal("0.4"), new BigDecimal("0.5"), new BigDecimal("0.6"), new BigDecimal("0.7"), new BigDecimal("0.8"), new BigDecimal("0.9"), new BigDecimal("1.0"), new BigDecimal("1.1"), new BigDecimal("1.2"), new BigDecimal("1.3"), new BigDecimal("1.4"), new BigDecimal("1.5")))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Belarusian (be)</li>
     * </ul>
     */
    FAMILY_16(
        (n) -> {
          // n % 10 = 1 and n % 100 != 11
          if (equal(n.remainder(BIG_DECIMAL_10), BIG_DECIMAL_1) && notEqual(n.remainder(BIG_DECIMAL_100), BIG_DECIMAL_11))
            return ONE;
          // n % 10 = 2..4 and n % 100 != 12..14
          if (inRange(n.remainder(BIG_DECIMAL_10), BIG_DECIMAL_2, BIG_DECIMAL_4)
              && notInRange(n.remainder(BIG_DECIMAL_100), BIG_DECIMAL_12, BIG_DECIMAL_14))
            return FEW;
          // n % 10 = 0 or n % 10 = 5..9 or n % 100 = 11..14
          if (equal(n.remainder(BIG_DECIMAL_10), BIG_DECIMAL_0)
              || inRange(n.remainder(BIG_DECIMAL_10), BIG_DECIMAL_5, BIG_DECIMAL_9)
              || inRange(n.remainder(BIG_DECIMAL_100), BIG_DECIMAL_11, BIG_DECIMAL_14))
            return MANY;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            FEW,
            MANY,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofInfiniteValues(1, 21, 31, 41, 51, 61, 71, 81, 101, 1001)),
            MapEntry.of(Cardinality.FEW, Range.ofInfiniteValues(2, 3, 4, 22, 23, 24, 32, 33, 34, 42, 43, 44, 52, 53, 54, 62, 102, 1002)),
            MapEntry.of(Cardinality.MANY, Range.ofInfiniteValues(0, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 100, 1000, 10000, 100000, 1000000))
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(new BigDecimal("0.1"), new BigDecimal("0.2"), new BigDecimal("0.3"), new BigDecimal("0.4"), new BigDecimal("0.5"), new BigDecimal("0.6"), new BigDecimal("0.7"), new BigDecimal("0.8"), new BigDecimal("0.9"), new BigDecimal("1.1"), new BigDecimal("1.2"), new BigDecimal("1.3"), new BigDecimal("1.4"), new BigDecimal("1.5"), new BigDecimal("1.6"), new BigDecimal("1.7"), new BigDecimal("10.1"), new BigDecimal("100.1"), new BigDecimal("1000.1")))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Breton (br)</li>
     * </ul>
     */
    FAMILY_17(
        (n) -> {
          // n % 10 = 1 and n % 100 != 11,71,91
          if (equal(n.remainder(BIG_DECIMAL_10), BIG_DECIMAL_1)
              && notInSet(n.remainder(BIG_DECIMAL_100), BIG_DECIMAL_11, BIG_DECIMAL_71, BIG_DECIMAL_91))
            return ONE;
          // n % 10 = 2 and n % 100 != 12,72,92
          if (equal(n.remainder(BIG_DECIMAL_10), BIG_DECIMAL_2)
              && notInSet(n.remainder(BIG_DECIMAL_100), BIG_DECIMAL_12, BIG_DECIMAL_72, BIG_DECIMAL_92))
            return TWO;
          // n % 10 = 3..4,9 and n % 100 != 10..19,70..79,90..99
          if ((inRange(n.remainder(BIG_DECIMAL_10), BIG_DECIMAL_3, BIG_DECIMAL_4) || equal(n.remainder(BIG_DECIMAL_10), BIG_DECIMAL_9))
              && notInRange(n.remainder(BIG_DECIMAL_100), BIG_DECIMAL_10, BIG_DECIMAL_19)
              && notInRange(n.remainder(BIG_DECIMAL_100), BIG_DECIMAL_70, BIG_DECIMAL_79)
              && notInRange(n.remainder(BIG_DECIMAL_100), BIG_DECIMAL_90, BIG_DECIMAL_99))
            return FEW;
          // n != 0 and n % 1000000 = 0
          if (notEqual(n, BIG_DECIMAL_0) && equal(n.remainder(BIG_DECIMAL_1_000_000), BIG_DECIMAL_0))
            return MANY;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            TWO,
            FEW,
            MANY,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofInfiniteValues(1, 21, 31, 41, 51, 61, 81, 101, 1001)),
            MapEntry.of(Cardinality.TWO, Range.ofInfiniteValues(2, 22, 32, 42, 52, 62, 82, 102, 1002)),
            MapEntry.of(Cardinality.FEW, Range.ofInfiniteValues(3, 4, 9, 23, 24, 29, 33, 34, 39, 43, 44, 49, 103, 1003)),
            MapEntry.of(Cardinality.MANY, Range.ofInfiniteValues(1000000)),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(0, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 100, 1000, 10000, 100000))
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(new BigDecimal("0.0"), new BigDecimal("0.1"), new BigDecimal("0.2"), new BigDecimal("0.3"), new BigDecimal("0.4"), new BigDecimal("0.5"), new BigDecimal("0.6"), new BigDecimal("0.7"), new BigDecimal("0.8"), new BigDecimal("0.9"), new BigDecimal("1.1"), new BigDecimal("1.2"), new BigDecimal("1.3"), new BigDecimal("1.4"), new BigDecimal("1.5"), new BigDecimal("1.6")))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Welsh (cy)</li>
     * </ul>
     */
    FAMILY_18(
        (n) -> {
          // n = 0
          if (equal(n, BIG_DECIMAL_0))
            return ZERO;
          // n = 1
          if (equal(n, BIG_DECIMAL_1))
            return ONE;
          // n = 2
          if (equal(n, BIG_DECIMAL_2))
            return TWO;
          // n = 3
          if (equal(n, BIG_DECIMAL_3))
            return FEW;
          // n = 6
          if (equal(n, BIG_DECIMAL_6))
            return MANY;

          return OTHER;
        },
        Sets.sortedSet(
            ZERO,
            ONE,
            TWO,
            FEW,
            MANY,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ZERO, Range.ofFiniteValues(0)),
            MapEntry.of(Cardinality.ONE, Range.ofFiniteValues(1)),
            MapEntry.of(Cardinality.TWO, Range.ofFiniteValues(2)),
            MapEntry.of(Cardinality.FEW, Range.ofFiniteValues(3)),
            MapEntry.of(Cardinality.MANY, Range.ofFiniteValues(6)),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 100, 1000, 10000, 100000, 1000000))
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(new BigDecimal("0.1"), new BigDecimal("0.2"), new BigDecimal("0.3"), new BigDecimal("0.4"), new BigDecimal("0.5"), new BigDecimal("0.6"), new BigDecimal("0.7"), new BigDecimal("0.8"), new BigDecimal("0.9"), new BigDecimal("1.1"), new BigDecimal("1.2"), new BigDecimal("1.3"), new BigDecimal("1.4"), new BigDecimal("1.5"), new BigDecimal("1.6"), new BigDecimal("1.7")))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Danish (da)</li>
     * </ul>
     */
    FAMILY_19(
        (n) -> {
          BigInteger i = NumberUtils.integerComponent(n);
          BigInteger t = NumberUtils.fractionalComponent(n.stripTrailingZeros());

          // n = 1 or t != 0 and i = 0,1
          if (equal(n, BIG_DECIMAL_1) || (notEqual(t, BIG_INTEGER_0) && inSet(i, BIG_INTEGER_0, BIG_INTEGER_1)))
            return ONE;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofFiniteValues(1)),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 100, 1000, 10000, 100000, 1000000))
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofFiniteValues(new BigDecimal("0.1"), new BigDecimal("0.2"), new BigDecimal("0.3"), new BigDecimal("0.4"), new BigDecimal("0.5"), new BigDecimal("0.6"), new BigDecimal("0.7"), new BigDecimal("0.8"), new BigDecimal("0.9"), new BigDecimal("1.0"), new BigDecimal("1.1"), new BigDecimal("1.2"), new BigDecimal("1.3"), new BigDecimal("1.4"), new BigDecimal("1.5"), new BigDecimal("1.6"))),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(new BigDecimal("2.0"), new BigDecimal("2.1"), new BigDecimal("2.2"), new BigDecimal("2.3"), new BigDecimal("2.4"), new BigDecimal("2.5"), new BigDecimal("2.6"), new BigDecimal("2.7"), new BigDecimal("2.8"), new BigDecimal("2.9"), new BigDecimal("3.0"), new BigDecimal("3.1"), new BigDecimal("3.2"), new BigDecimal("3.3"), new BigDecimal("3.4")))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Irish (ga)</li>
     * </ul>
     */
    FAMILY_20(
        (n) -> {
          // n = 1
          if (equal(n, BIG_DECIMAL_1))
            return ONE;
          // n = 2
          if (equal(n, BIG_DECIMAL_2))
            return TWO;
          // n = 3..6
          if (inRange(n, BIG_DECIMAL_3, BIG_DECIMAL_6))
            return FEW;
          // n = 7..10
          if (inRange(n, BIG_DECIMAL_7, BIG_DECIMAL_10))
            return MANY;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            TWO,
            FEW,
            MANY,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofFiniteValues(1)),
            MapEntry.of(Cardinality.TWO, Range.ofFiniteValues(2)),
            MapEntry.of(Cardinality.FEW, Range.ofFiniteValues(3, 4, 5, 6)),
            MapEntry.of(Cardinality.MANY, Range.ofFiniteValues(7, 8, 9, 10)),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(0, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 100, 1000, 10000, 100000, 1000000))
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(new BigDecimal("0.0"), new BigDecimal("0.1"), new BigDecimal("0.2"), new BigDecimal("0.3"), new BigDecimal("0.4"), new BigDecimal("0.5"), new BigDecimal("0.6"), new BigDecimal("0.7"), new BigDecimal("0.8"), new BigDecimal("0.9"), new BigDecimal("1.1"), new BigDecimal("1.2"), new BigDecimal("1.3"), new BigDecimal("1.4"), new BigDecimal("1.5"), new BigDecimal("1.6"), new BigDecimal("10.1")))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Scottish Gaelic (gd)</li>
     * </ul>
     */
    FAMILY_21(
        (n) -> {
          // n = 1,11
          if (inSet(n, BIG_DECIMAL_1, BIG_DECIMAL_11))
            return ONE;
          // n = 2,12
          if (inSet(n, BIG_DECIMAL_2, BIG_DECIMAL_12))
            return TWO;
          // n = 3..10,13..19
          if (inRange(n, BIG_DECIMAL_3, BIG_DECIMAL_10) || inRange(n, BIG_DECIMAL_13, BIG_DECIMAL_19))
            return FEW;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            TWO,
            FEW,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofFiniteValues(1, 11)),
            MapEntry.of(Cardinality.TWO, Range.ofFiniteValues(2, 12)),
            MapEntry.of(Cardinality.FEW, Range.ofFiniteValues(3, 4, 5, 6, 7, 8, 9, 10, 13, 14, 15, 16, 17, 18, 19)),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(0, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 100, 1000, 10000, 100000, 1000000))
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(new BigDecimal("0.0"), new BigDecimal("0.1"), new BigDecimal("0.2"), new BigDecimal("0.3"), new BigDecimal("0.4"), new BigDecimal("0.5"), new BigDecimal("0.6"), new BigDecimal("0.7"), new BigDecimal("0.8"), new BigDecimal("0.9"), new BigDecimal("1.1"), new BigDecimal("1.2"), new BigDecimal("1.3"), new BigDecimal("1.4"), new BigDecimal("1.5"), new BigDecimal("1.6"), new BigDecimal("10.1")))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Manx (gv)</li>
     * </ul>
     */
    FAMILY_22(
        (n) -> {
          int v = NumberUtils.numberOfDecimalPlaces(n);
          BigInteger i = NumberUtils.integerComponent(n);

          // v = 0 and i % 10 = 1
          if (v == 0 && equal(i.mod(BIG_INTEGER_10), BIG_INTEGER_1))
            return ONE;
          // v = 0 and i % 10 = 2
          if (v == 0 && equal(i.mod(BIG_INTEGER_10), BIG_INTEGER_2))
            return TWO;
          // v = 0 and i % 100 = 0,20,40,60,80
          if (v == 0 && inSet(i.mod(BIG_INTEGER_100), BIG_INTEGER_0, BIG_INTEGER_20, BIG_INTEGER_40, BIG_INTEGER_60, BIG_INTEGER_80))
            return FEW;
          // v != 0
          if (v != 0)
            return MANY;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            TWO,
            FEW,
            MANY,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofInfiniteValues(1, 11, 21, 31, 41, 51, 61, 71, 101, 1001)),
            MapEntry.of(Cardinality.TWO, Range.ofInfiniteValues(2, 12, 22, 32, 42, 52, 62, 72, 102, 1002)),
            MapEntry.of(Cardinality.FEW, Range.ofInfiniteValues(0, 20, 40, 60, 80, 100, 120, 140, 1000, 10000, 100000, 1000000)),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(3, 4, 5, 6, 7, 8, 9, 10, 13, 14, 15, 16, 17, 18, 19, 23, 103, 1003))
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.MANY, Range.ofInfiniteValues(new BigDecimal("0.0"), new BigDecimal("0.1"), new BigDecimal("0.2"), new BigDecimal("0.3"), new BigDecimal("0.4"), new BigDecimal("0.5"), new BigDecimal("0.6"), new BigDecimal("0.7"), new BigDecimal("0.8"), new BigDecimal("0.9"), new BigDecimal("1.0"), new BigDecimal("1.1"), new BigDecimal("1.2"), new BigDecimal("1.3"), new BigDecimal("1.4"), new BigDecimal("1.5")))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Hebrew (he)</li>
     * </ul>
     */
    FAMILY_23(
        (n) -> {
          int v = NumberUtils.numberOfDecimalPlaces(n);
          BigInteger i = NumberUtils.integerComponent(n);

          // i = 1 and v = 0
          if (equal(i, BIG_INTEGER_1) && v == 0)
            return ONE;
          // i = 2 and v = 0
          if (equal(i, BIG_INTEGER_2) && v == 0)
            return TWO;
          // v = 0 and n != 0..10 and n % 10 = 0
          if (v == 0
              && notInRange(n, BIG_DECIMAL_0, BIG_DECIMAL_10)
              && equal(n.remainder(BIG_DECIMAL_10), BIG_DECIMAL_0))
            return MANY;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            TWO,
            MANY,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofFiniteValues(1)),
            MapEntry.of(Cardinality.TWO, Range.ofFiniteValues(2)),
            MapEntry.of(Cardinality.MANY, Range.ofInfiniteValues(20, 30, 40, 50, 60, 70, 80, 90, 100, 1000, 10000, 100000, 1000000)),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(0, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 101, 1001))
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(new BigDecimal("0.0"), new BigDecimal("0.1"), new BigDecimal("0.2"), new BigDecimal("0.3"), new BigDecimal("0.4"), new BigDecimal("0.5"), new BigDecimal("0.6"), new BigDecimal("0.7"), new BigDecimal("0.8"), new BigDecimal("0.9"), new BigDecimal("1.0"), new BigDecimal("1.1"), new BigDecimal("1.2"), new BigDecimal("1.3"), new BigDecimal("1.4"), new BigDecimal("1.5")))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Icelandic (is)</li>
     * </ul>
     */
    FAMILY_24(
        (n) -> {
          BigInteger i = NumberUtils.integerComponent(n);
          BigInteger t = NumberUtils.fractionalComponent(n.stripTrailingZeros());

          // t = 0 and i % 10 = 1 and i % 100 != 11 or t != 0
          if ((equal(t, BIG_INTEGER_0) && equal(i.mod(BIG_INTEGER_10), BIG_INTEGER_1) && notEqual(i.mod(BIG_INTEGER_100), BIG_INTEGER_11))
              || notEqual(t, BIG_INTEGER_0))
            return ONE;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofInfiniteValues(1, 21, 31, 41, 51, 61, 71, 81, 101, 1001)),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 100, 1000, 10000, 100000, 1000000))
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofInfiniteValues(new BigDecimal("0.1"), new BigDecimal("0.2"), new BigDecimal("0.3"), new BigDecimal("0.4"), new BigDecimal("0.5"), new BigDecimal("0.6"), new BigDecimal("0.7"), new BigDecimal("0.8"), new BigDecimal("0.9"), new BigDecimal("1.0"), new BigDecimal("1.1"), new BigDecimal("1.2"), new BigDecimal("1.3"), new BigDecimal("1.4"), new BigDecimal("1.5"), new BigDecimal("1.6"), new BigDecimal("10.1"), new BigDecimal("100.1"), new BigDecimal("1000.1")))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Colognian (ksh)</li>
     * </ul>
     */
    FAMILY_25(
        (n) -> {
          // n = 0
          if (equal(n, BIG_DECIMAL_0))
            return ZERO;
          // n = 1
          if (equal(n, BIG_DECIMAL_1))
            return ONE;

          return OTHER;
        },
        Sets.sortedSet(
            ZERO,
            ONE,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ZERO, Range.ofFiniteValues(0)),
            MapEntry.of(Cardinality.ONE, Range.ofFiniteValues(1)),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 100, 1000, 10000, 100000, 1000000))
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(new BigDecimal("0.1"), new BigDecimal("0.2"), new BigDecimal("0.3"), new BigDecimal("0.4"), new BigDecimal("0.5"), new BigDecimal("0.6"), new BigDecimal("0.7"), new BigDecimal("0.8"), new BigDecimal("0.9"), new BigDecimal("1.1"), new BigDecimal("1.2"), new BigDecimal("1.3"), new BigDecimal("1.4"), new BigDecimal("1.5"), new BigDecimal("1.6"), new BigDecimal("1.7")))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Langi (lag)</li>
     * </ul>
     */
    FAMILY_26(
        (n) -> {
          BigInteger i = NumberUtils.integerComponent(n);

          // n = 0
          if (equal(n, BIG_DECIMAL_0))
            return ZERO;
          // i = 0,1 and n != 0
          if (inSet(i, BIG_INTEGER_0, BIG_INTEGER_1) && notEqual(n, BIG_DECIMAL_0))
            return ONE;

          return OTHER;
        },
        Sets.sortedSet(
            ZERO,
            ONE,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ZERO, Range.ofFiniteValues(0)),
            MapEntry.of(Cardinality.ONE, Range.ofFiniteValues(1)),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 100, 1000, 10000, 100000, 1000000))
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofFiniteValues(new BigDecimal("0.1"), new BigDecimal("0.2"), new BigDecimal("0.3"), new BigDecimal("0.4"), new BigDecimal("0.5"), new BigDecimal("0.6"), new BigDecimal("0.7"), new BigDecimal("0.8"), new BigDecimal("0.9"), new BigDecimal("1.0"), new BigDecimal("1.1"), new BigDecimal("1.2"), new BigDecimal("1.3"), new BigDecimal("1.4"), new BigDecimal("1.5"), new BigDecimal("1.6"))),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(new BigDecimal("2.0"), new BigDecimal("2.1"), new BigDecimal("2.2"), new BigDecimal("2.3"), new BigDecimal("2.4"), new BigDecimal("2.5"), new BigDecimal("2.6"), new BigDecimal("2.7"), new BigDecimal("2.8"), new BigDecimal("2.9"), new BigDecimal("3.0"), new BigDecimal("3.1"), new BigDecimal("3.2"), new BigDecimal("3.3"), new BigDecimal("3.4"), new BigDecimal("3.5")))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Lithuanian (lt)</li>
     * </ul>
     */
    FAMILY_27(
        (n) -> {
          BigInteger f = NumberUtils.fractionalComponent(n);

          // n % 10 = 1 and n % 100 != 11..19
          if (equal(n.remainder(BIG_DECIMAL_10), BIG_DECIMAL_1)
              && notInRange(n.remainder(BIG_DECIMAL_100), BIG_DECIMAL_11, BIG_DECIMAL_19))
            return ONE;
          // n % 10 = 2..9 and n % 100 != 11..19
          if (inRange(n.remainder(BIG_DECIMAL_10), BIG_DECIMAL_2, BIG_DECIMAL_9)
              && notInRange(n.remainder(BIG_DECIMAL_100), BIG_DECIMAL_11, BIG_DECIMAL_19))
            return FEW;
          // f != 0
          if (notEqual(f, BIG_INTEGER_0))
            return MANY;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            FEW,
            MANY,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofInfiniteValues(1, 21, 31, 41, 51, 61, 71, 81, 101, 1001)),
            MapEntry.of(Cardinality.FEW, Range.ofInfiniteValues(2, 3, 4, 5, 6, 7, 8, 9, 22, 23, 24, 25, 26, 27, 28, 29, 102, 1002)),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(0, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 30, 40, 50, 60, 100, 1000, 10000, 100000, 1000000))
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.MANY, Range.ofInfiniteValues(new BigDecimal("0.1"), new BigDecimal("0.2"), new BigDecimal("0.3"), new BigDecimal("0.4"), new BigDecimal("0.5"), new BigDecimal("0.6"), new BigDecimal("0.7"), new BigDecimal("0.8"), new BigDecimal("0.9"), new BigDecimal("1.1"), new BigDecimal("1.2"), new BigDecimal("1.3"), new BigDecimal("1.4"), new BigDecimal("1.5"), new BigDecimal("1.6"), new BigDecimal("1.7"), new BigDecimal("10.1"), new BigDecimal("100.1"), new BigDecimal("1000.1")))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Macedonian (mk)</li>
     * </ul>
     */
    FAMILY_28(
        (n) -> {
          int v = NumberUtils.numberOfDecimalPlaces(n);
          BigInteger i = NumberUtils.integerComponent(n);
          BigInteger f = NumberUtils.fractionalComponent(n);

          // v = 0 and i % 10 = 1 or f % 10 = 1
          if ((v == 0 && equal(i.mod(BIG_INTEGER_10), BIG_INTEGER_1))
              || equal(f.mod(BIG_INTEGER_10), BIG_INTEGER_1))
            return ONE;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofInfiniteValues(1, 11, 21, 31, 41, 51, 61, 71, 101, 1001)),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 13, 14, 15, 16, 17, 100, 1000, 10000, 100000, 1000000))
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofInfiniteValues(new BigDecimal("0.1"), new BigDecimal("1.1"), new BigDecimal("2.1"), new BigDecimal("3.1"), new BigDecimal("4.1"), new BigDecimal("5.1"), new BigDecimal("6.1"), new BigDecimal("7.1"), new BigDecimal("10.1"), new BigDecimal("100.1"), new BigDecimal("1000.1"))),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(new BigDecimal("0.2"), new BigDecimal("0.3"), new BigDecimal("0.4"), new BigDecimal("0.5"), new BigDecimal("0.6"), new BigDecimal("0.7"), new BigDecimal("0.8"), new BigDecimal("0.9"), new BigDecimal("1.0"), new BigDecimal("1.2"), new BigDecimal("1.3"), new BigDecimal("1.4"), new BigDecimal("1.5"), new BigDecimal("1.6"), new BigDecimal("1.7")))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Maltese (mt)</li>
     * </ul>
     */
    FAMILY_29(
        (n) -> {
          // n = 1
          if (equal(n, BIG_DECIMAL_1))
            return ONE;
          // n = 0 or n % 100 = 2..10
          if (equal(n, BIG_DECIMAL_0) || inRange(n.remainder(BIG_DECIMAL_100), BIG_DECIMAL_2, BIG_DECIMAL_10))
            return FEW;
          // n % 100 = 11..19
          if (inRange(n.remainder(BIG_DECIMAL_100), BIG_DECIMAL_11, BIG_DECIMAL_19))
            return MANY;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            FEW,
            MANY,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofFiniteValues(1)),
            MapEntry.of(Cardinality.FEW, Range.ofInfiniteValues(0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 102, 103, 104, 105, 106, 107, 1002)),
            MapEntry.of(Cardinality.MANY, Range.ofInfiniteValues(11, 12, 13, 14, 15, 16, 17, 18, 19, 111, 112, 113, 114, 115, 116, 117, 1011)),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 100, 1000, 10000, 100000, 1000000))
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(new BigDecimal("0.1"), new BigDecimal("0.2"), new BigDecimal("0.3"), new BigDecimal("0.4"), new BigDecimal("0.5"), new BigDecimal("0.6"), new BigDecimal("0.7"), new BigDecimal("0.8"), new BigDecimal("0.9"), new BigDecimal("1.1"), new BigDecimal("1.2"), new BigDecimal("1.3"), new BigDecimal("1.4"), new BigDecimal("1.5"), new BigDecimal("1.6"), new BigDecimal("1.7"), new BigDecimal("10.1")))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Polish (pl)</li>
     * </ul>
     */
    FAMILY_30(
        (n) -> {
          int v = NumberUtils.numberOfDecimalPlaces(n);
          BigInteger i = NumberUtils.integerComponent(n);

          // i = 1 and v = 0
          if (equal(i, BIG_INTEGER_1) && v == 0)
            return ONE;
          // v = 0 and i % 10 = 2..4 and i % 100 != 12..14
          if (v == 0
              && inRange(i.mod(BIG_INTEGER_10), BIG_INTEGER_2, BIG_INTEGER_4)
              && notInRange(i.mod(BIG_INTEGER_100), BIG_INTEGER_12, BIG_INTEGER_14))
            return FEW;
          // v = 0 and i != 1 and i % 10 = 0..1 or v = 0 and i % 10 = 5..9 or v = 0 and i % 100 = 12..14
          if ((v == 0 && notEqual(i, BIG_INTEGER_1) && inRange(i.mod(BIG_INTEGER_10), BIG_INTEGER_0, BIG_INTEGER_1))
              || (v == 0 && inRange(i.mod(BIG_INTEGER_10), BIG_INTEGER_5, BIG_INTEGER_9))
              || (v == 0 && inRange(i.mod(BIG_INTEGER_100), BIG_INTEGER_12, BIG_INTEGER_14)))
            return MANY;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            FEW,
            MANY,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofFiniteValues(1)),
            MapEntry.of(Cardinality.FEW, Range.ofInfiniteValues(2, 3, 4, 22, 23, 24, 32, 33, 34, 42, 43, 44, 52, 53, 54, 62, 102, 1002)),
            MapEntry.of(Cardinality.MANY, Range.ofInfiniteValues(0, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 100, 1000, 10000, 100000, 1000000))
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(new BigDecimal("0.0"), new BigDecimal("0.1"), new BigDecimal("0.2"), new BigDecimal("0.3"), new BigDecimal("0.4"), new BigDecimal("0.5"), new BigDecimal("0.6"), new BigDecimal("0.7"), new BigDecimal("0.8"), new BigDecimal("0.9"), new BigDecimal("1.0"), new BigDecimal("1.1"), new BigDecimal("1.2"), new BigDecimal("1.3"), new BigDecimal("1.4"), new BigDecimal("1.5")))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Portuguese (pt)</li>
     * </ul>
     */
    FAMILY_31(
        (n) -> {
          BigInteger i = NumberUtils.integerComponent(n);

          // i = 0..1
          if (inRange(i, BIG_INTEGER_0, BIG_INTEGER_1))
            return ONE;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofFiniteValues(0, 1)),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 100, 1000, 10000, 100000, 1000000))
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofFiniteValues(new BigDecimal("0.0"), new BigDecimal("0.1"), new BigDecimal("0.2"), new BigDecimal("0.3"), new BigDecimal("0.4"), new BigDecimal("0.5"), new BigDecimal("0.6"), new BigDecimal("0.7"), new BigDecimal("0.8"), new BigDecimal("0.9"), new BigDecimal("1.0"), new BigDecimal("1.1"), new BigDecimal("1.2"), new BigDecimal("1.3"), new BigDecimal("1.4"), new BigDecimal("1.5"))),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(new BigDecimal("2.0"), new BigDecimal("2.1"), new BigDecimal("2.2"), new BigDecimal("2.3"), new BigDecimal("2.4"), new BigDecimal("2.5"), new BigDecimal("2.6"), new BigDecimal("2.7"), new BigDecimal("2.8"), new BigDecimal("2.9"), new BigDecimal("3.0"), new BigDecimal("3.1"), new BigDecimal("3.2"), new BigDecimal("3.3"), new BigDecimal("3.4"), new BigDecimal("3.5")))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Tachelhit (shi)</li>
     * </ul>
     */
    FAMILY_32(
        (n) -> {
          BigInteger i = NumberUtils.integerComponent(n);

          // i = 0 or n = 1
          if (equal(i, BIG_INTEGER_0) || equal(n, BIG_DECIMAL_1))
            return ONE;
          // n = 2..10
          if (inRange(n, BIG_DECIMAL_2, BIG_DECIMAL_10))
            return FEW;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            FEW,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofFiniteValues(0, 1)),
            MapEntry.of(Cardinality.FEW, Range.ofFiniteValues(2, 3, 4, 5, 6, 7, 8, 9, 10)),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 100, 1000, 10000, 100000, 1000000))
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofFiniteValues(new BigDecimal("0.0"), new BigDecimal("0.01"), new BigDecimal("0.02"), new BigDecimal("0.03"), new BigDecimal("0.04"), new BigDecimal("0.1"), new BigDecimal("0.2"), new BigDecimal("0.3"), new BigDecimal("0.4"), new BigDecimal("0.5"), new BigDecimal("0.6"), new BigDecimal("0.7"), new BigDecimal("0.8"), new BigDecimal("0.9"), new BigDecimal("1.0"))),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(new BigDecimal("1.1"), new BigDecimal("1.2"), new BigDecimal("1.3"), new BigDecimal("1.4"), new BigDecimal("1.5"), new BigDecimal("1.6"), new BigDecimal("1.7"), new BigDecimal("1.8"), new BigDecimal("1.9"), new BigDecimal("2.1"), new BigDecimal("2.2"), new BigDecimal("2.3"), new BigDecimal("2.4"), new BigDecimal("2.5"), new BigDecimal("2.6"), new BigDecimal("2.7"), new BigDecimal("10.1")))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Sinhalese (si)</li>
     * </ul>
     */
    FAMILY_33(
        (n) -> {
          BigInteger i = NumberUtils.integerComponent(n);
          BigInteger f = NumberUtils.fractionalComponent(n);

          // n = 0,1 or i = 0 and f = 1
          if (inSet(n, BIG_DECIMAL_0, BIG_DECIMAL_1)
              || (equal(i, BIG_INTEGER_0) && equal(f, BIG_INTEGER_1)))
            return ONE;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofFiniteValues(0, 1)),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 100, 1000, 10000, 100000, 1000000))
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofFiniteValues(new BigDecimal("0.0001"), new BigDecimal("0.001"), new BigDecimal("0.01"), new BigDecimal("0.1"))),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(new BigDecimal("0.2"), new BigDecimal("0.3"), new BigDecimal("0.4"), new BigDecimal("0.5"), new BigDecimal("0.6"), new BigDecimal("0.7"), new BigDecimal("0.8"), new BigDecimal("0.9"), new BigDecimal("1.1"), new BigDecimal("1.2"), new BigDecimal("1.3"), new BigDecimal("1.4"), new BigDecimal("1.5"), new BigDecimal("1.6"), new BigDecimal("1.7"), new BigDecimal("1.8")))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Slovenian (sl)</li>
     * </ul>
     */
    FAMILY_34(
        (n) -> {
          int v = NumberUtils.numberOfDecimalPlaces(n);
          BigInteger i = NumberUtils.integerComponent(n);

          // v = 0 and i % 100 = 1
          if (v == 0 && equal(i.mod(BIG_INTEGER_100), BIG_INTEGER_1))
            return ONE;
          // v = 0 and i % 100 = 2
          if (v == 0 && equal(i.mod(BIG_INTEGER_100), BIG_INTEGER_2))
            return TWO;
          // v = 0 and i % 100 = 3..4 or v != 0
          if ((v == 0 && inRange(i.mod(BIG_INTEGER_100), BIG_INTEGER_3, BIG_INTEGER_4))
              || v != 0)
            return FEW;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            TWO,
            FEW,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofInfiniteValues(1, 101, 201, 301, 401, 501, 601, 701, 1001)),
            MapEntry.of(Cardinality.TWO, Range.ofInfiniteValues(2, 102, 202, 302, 402, 502, 602, 702, 1002)),
            MapEntry.of(Cardinality.FEW, Range.ofInfiniteValues(3, 4, 103, 104, 203, 204, 303, 304, 403, 404, 503, 504, 603, 604, 703, 704, 1003)),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(0, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 100, 1000, 10000, 100000, 1000000))
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.FEW, Range.ofInfiniteValues(new BigDecimal("0.0"), new BigDecimal("0.1"), new BigDecimal("0.2"), new BigDecimal("0.3"), new BigDecimal("0.4"), new BigDecimal("0.5"), new BigDecimal("0.6"), new BigDecimal("0.7"), new BigDecimal("0.8"), new BigDecimal("0.9"), new BigDecimal("1.0"), new BigDecimal("1.1"), new BigDecimal("1.2"), new BigDecimal("1.3"), new BigDecimal("1.4"), new BigDecimal("1.5")))
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Central Atlas Tamazight (tzm)</li>
     * </ul>
     */
    FAMILY_35(
        (n) -> {
          // n = 0..1 or n = 11..99
          if (inRange(n, BIG_DECIMAL_0, BIG_DECIMAL_1) || inRange(n, BIG_DECIMAL_11, BIG_DECIMAL_99))
            return ONE;

          return OTHER;
        },
        Sets.sortedSet(
            ONE,
            OTHER
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.ONE, Range.ofFiniteValues(0, 1, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24)),
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(2, 3, 4, 5, 6, 7, 8, 9, 10, 100, 101, 102, 103, 104, 105, 106, 1000, 10000, 100000, 1000000))
        ),
        Maps.sortedMap(
            MapEntry.of(Cardinality.OTHER, Range.ofInfiniteValues(new BigDecimal("0.1"), new BigDecimal("0.2"), new BigDecimal("0.3"), new BigDecimal("0.4"), new BigDecimal("0.5"), new BigDecimal("0.6"), new BigDecimal("0.7"), new BigDecimal("0.8"), new BigDecimal("0.9"), new BigDecimal("1.1"), new BigDecimal("1.2"), new BigDecimal("1.3"), new BigDecimal("1.4"), new BigDecimal("1.5"), new BigDecimal("1.6"), new BigDecimal("1.7")))
        )
    );

    @Nonnull
    private static final Map<String, CardinalityFamily> CARDINALITY_FAMILIES_BY_LANGUAGE_CODE;
    @Nonnull
    private static final SortedSet<String> SUPPORTED_LANGUAGE_CODES;

    @Nonnull
    private final Function<BigDecimal, Cardinality> cardinalityFunction;
    @Nonnull
    private final SortedSet<Cardinality> supportedCardinalities;
    @Nonnull
    private final SortedMap<Cardinality, Range<Integer>> exampleIntegerValuesByCardinality;
    @Nonnull
    private final SortedMap<Cardinality, Range<BigDecimal>> exampleDecimalValuesByCardinality;

    /**
     * Constructs a cardinality family.
     *
     * @param cardinalityFunction               the cardinality-determining function for this cardinality family, not null
     * @param supportedCardinalities            the cardinalities supported by this family sorted by the natural ordering of {@link Cardinality}, not null
     * @param exampleIntegerValuesByCardinality a mapping of cardinalities to example integer values for this cardinality family sorted by the natural ordering of {@link Cardinality}, not null
     * @param exampleDecimalValuesByCardinality a mapping of cardinalities to example decimal values for this cardinality family sorted by the natural ordering of {@link Cardinality}, not null
     */
    CardinalityFamily(@Nonnull Function<BigDecimal, Cardinality> cardinalityFunction,
                      @Nonnull SortedSet<Cardinality> supportedCardinalities,
                      @Nonnull SortedMap<Cardinality, Range<Integer>> exampleIntegerValuesByCardinality,
                      @Nonnull SortedMap<Cardinality, Range<BigDecimal>> exampleDecimalValuesByCardinality) {
      requireNonNull(cardinalityFunction);
      requireNonNull(supportedCardinalities);
      requireNonNull(exampleIntegerValuesByCardinality);
      requireNonNull(exampleDecimalValuesByCardinality);

      this.cardinalityFunction = cardinalityFunction;
      this.supportedCardinalities = supportedCardinalities;
      this.exampleIntegerValuesByCardinality = exampleIntegerValuesByCardinality;
      this.exampleDecimalValuesByCardinality = exampleDecimalValuesByCardinality;
    }

    static {
      CARDINALITY_FAMILIES_BY_LANGUAGE_CODE = Collections.unmodifiableMap(new HashMap<String, CardinalityFamily>() {{
        put("af", CardinalityFamily.FAMILY_1); // Afrikaans
        put("ak", CardinalityFamily.FAMILY_4); // Akan
        put("am", CardinalityFamily.FAMILY_5); // Amharic
        put("ar", CardinalityFamily.FAMILY_9); // Arabic
        put("ars", CardinalityFamily.FAMILY_9); // Najdi Arabic
        put("as", CardinalityFamily.FAMILY_5); // Assamese
        put("asa", CardinalityFamily.FAMILY_1); // Asu
        put("ast", CardinalityFamily.FAMILY_3); // Asturian
        put("az", CardinalityFamily.FAMILY_1); // Azeri
        put("be", CardinalityFamily.FAMILY_16); // Belarusian
        put("bem", CardinalityFamily.FAMILY_1); // Bemba
        put("bez", CardinalityFamily.FAMILY_1); // Bena
        put("bg", CardinalityFamily.FAMILY_1); // Bulgarian
        put("bh", CardinalityFamily.FAMILY_4); // Bihari
        put("bm", CardinalityFamily.FAMILY_2); // Bambara
        put("bn", CardinalityFamily.FAMILY_5); // Bangla
        put("bo", CardinalityFamily.FAMILY_2); // Tibetan
        put("br", CardinalityFamily.FAMILY_17); // Breton
        put("brx", CardinalityFamily.FAMILY_1); // Bodo
        put("bs", CardinalityFamily.FAMILY_7); // Bosnian
        put("ca", CardinalityFamily.FAMILY_3); // Catalan
        put("ce", CardinalityFamily.FAMILY_1); // Chechen
        put("cgg", CardinalityFamily.FAMILY_1); // Chiga
        put("chr", CardinalityFamily.FAMILY_1); // Cherokee
        put("ckb", CardinalityFamily.FAMILY_1); // Central Kurdish
        put("cs", CardinalityFamily.FAMILY_10); // Czech
        put("cy", CardinalityFamily.FAMILY_18); // Welsh
        put("da", CardinalityFamily.FAMILY_19); // Danish
        put("de", CardinalityFamily.FAMILY_3); // German
        put("dsb", CardinalityFamily.FAMILY_11); // Lower Sorbian
        put("dv", CardinalityFamily.FAMILY_1); // Divehi
        put("dz", CardinalityFamily.FAMILY_2); // Dzongkha
        put("ee", CardinalityFamily.FAMILY_1); // Ewe
        put("el", CardinalityFamily.FAMILY_1); // Greek
        put("en", CardinalityFamily.FAMILY_3); // English
        put("eo", CardinalityFamily.FAMILY_1); // Esperanto
        put("es", CardinalityFamily.FAMILY_1); // Spanish
        put("et", CardinalityFamily.FAMILY_3); // Estonian
        put("eu", CardinalityFamily.FAMILY_1); // Basque
        put("fa", CardinalityFamily.FAMILY_5); // Persian
        put("ff", CardinalityFamily.FAMILY_8); // Fulah
        put("fi", CardinalityFamily.FAMILY_3); // Finnish
        put("fil", CardinalityFamily.FAMILY_12); // Filipino
        put("fo", CardinalityFamily.FAMILY_1); // Faroese
        put("fr", CardinalityFamily.FAMILY_8); // French
        put("fur", CardinalityFamily.FAMILY_1); // Friulian
        put("fy", CardinalityFamily.FAMILY_3); // Western Frisian
        put("ga", CardinalityFamily.FAMILY_20); // Irish
        put("gd", CardinalityFamily.FAMILY_21); // Scottish Gaelic
        put("gl", CardinalityFamily.FAMILY_3); // Galician
        put("gsw", CardinalityFamily.FAMILY_1); // Swiss German
        put("gu", CardinalityFamily.FAMILY_5); // Gujarati
        put("guw", CardinalityFamily.FAMILY_4); // Gun
        put("gv", CardinalityFamily.FAMILY_22); // Manx
        put("ha", CardinalityFamily.FAMILY_1); // Hausa
        put("haw", CardinalityFamily.FAMILY_1); // Hawaiian
        put("he", CardinalityFamily.FAMILY_23); // Hebrew
        put("hi", CardinalityFamily.FAMILY_5); // Hindi
        put("hr", CardinalityFamily.FAMILY_7); // Croatian
        put("hsb", CardinalityFamily.FAMILY_11); // Upper Sorbian
        put("hu", CardinalityFamily.FAMILY_1); // Hungarian
        put("hy", CardinalityFamily.FAMILY_8); // Armenian
        put("id", CardinalityFamily.FAMILY_2); // Indonesian
        put("ig", CardinalityFamily.FAMILY_2); // Igbo
        put("ii", CardinalityFamily.FAMILY_2); // Sichuan Yi
        put("is", CardinalityFamily.FAMILY_24); // Icelandic
        put("it", CardinalityFamily.FAMILY_3); // Italian
        put("iu", CardinalityFamily.FAMILY_6); // Inuktitut
        put("ja", CardinalityFamily.FAMILY_2); // Japanese
        put("jbo", CardinalityFamily.FAMILY_2); // Lojban
        put("jgo", CardinalityFamily.FAMILY_1); // Ngomba
        put("jmc", CardinalityFamily.FAMILY_1); // Machame
        put("jv", CardinalityFamily.FAMILY_2); // Javanese
        put("jw", CardinalityFamily.FAMILY_2); // Javanese
        put("ka", CardinalityFamily.FAMILY_1); // Georgian
        put("kab", CardinalityFamily.FAMILY_8); // Kabyle
        put("kaj", CardinalityFamily.FAMILY_1); // Jju
        put("kcg", CardinalityFamily.FAMILY_1); // Tyap
        put("kde", CardinalityFamily.FAMILY_2); // Makonde
        put("kea", CardinalityFamily.FAMILY_2); // Kabuverdianu
        put("kk", CardinalityFamily.FAMILY_1); // Kazakh
        put("kkj", CardinalityFamily.FAMILY_1); // Kako
        put("kl", CardinalityFamily.FAMILY_1); // Greenlandic
        put("km", CardinalityFamily.FAMILY_2); // Khmer
        put("kn", CardinalityFamily.FAMILY_5); // Kannada
        put("ko", CardinalityFamily.FAMILY_2); // Korean
        put("ks", CardinalityFamily.FAMILY_1); // Kashmiri
        put("ksb", CardinalityFamily.FAMILY_1); // Shambala
        put("ksh", CardinalityFamily.FAMILY_25); // Colognian
        put("ku", CardinalityFamily.FAMILY_1); // Kurdish
        put("kw", CardinalityFamily.FAMILY_6); // Cornish
        put("ky", CardinalityFamily.FAMILY_1); // Kirghiz
        put("lag", CardinalityFamily.FAMILY_26); // Langi
        put("lb", CardinalityFamily.FAMILY_1); // Luxembourgish
        put("lg", CardinalityFamily.FAMILY_1); // Ganda
        put("lkt", CardinalityFamily.FAMILY_2); // Lakota
        put("ln", CardinalityFamily.FAMILY_4); // Lingala
        put("lo", CardinalityFamily.FAMILY_2); // Lao
        put("lt", CardinalityFamily.FAMILY_27); // Lithuanian
        put("lv", CardinalityFamily.FAMILY_13); // Latvian
        put("mas", CardinalityFamily.FAMILY_1); // Masai
        put("mg", CardinalityFamily.FAMILY_4); // Malagasy
        put("mgo", CardinalityFamily.FAMILY_1); // Metaʼ
        put("mk", CardinalityFamily.FAMILY_28); // Macedonian
        put("ml", CardinalityFamily.FAMILY_1); // Malayalam
        put("mn", CardinalityFamily.FAMILY_1); // Mongolian
        put("mo", CardinalityFamily.FAMILY_14); // Moldovan
        put("mr", CardinalityFamily.FAMILY_5); // Marathi
        put("ms", CardinalityFamily.FAMILY_2); // Malay
        put("mt", CardinalityFamily.FAMILY_29); // Maltese
        put("my", CardinalityFamily.FAMILY_2); // Burmese
        put("nah", CardinalityFamily.FAMILY_1); // Nahuatl
        put("naq", CardinalityFamily.FAMILY_6); // Nama
        put("nb", CardinalityFamily.FAMILY_1); // Norwegian Bokmål
        put("nd", CardinalityFamily.FAMILY_1); // North Ndebele
        put("ne", CardinalityFamily.FAMILY_1); // Nepali
        put("nl", CardinalityFamily.FAMILY_3); // Dutch
        put("nn", CardinalityFamily.FAMILY_1); // Norwegian Nynorsk
        put("nnh", CardinalityFamily.FAMILY_1); // Ngiemboon
        put("no", CardinalityFamily.FAMILY_1); // Norwegian
        put("nqo", CardinalityFamily.FAMILY_2); // N’Ko
        put("nr", CardinalityFamily.FAMILY_1); // South Ndebele
        put("nso", CardinalityFamily.FAMILY_4); // Northern Sotho
        put("ny", CardinalityFamily.FAMILY_1); // Nyanja
        put("nyn", CardinalityFamily.FAMILY_1); // Nyankole
        put("om", CardinalityFamily.FAMILY_1); // Oromo
        put("or", CardinalityFamily.FAMILY_1); // Odia
        put("os", CardinalityFamily.FAMILY_1); // Ossetian
        put("pa", CardinalityFamily.FAMILY_4); // Punjabi
        put("pap", CardinalityFamily.FAMILY_1); // Papiamento
        put("pl", CardinalityFamily.FAMILY_30); // Polish
        put("prg", CardinalityFamily.FAMILY_13); // Prussian
        put("ps", CardinalityFamily.FAMILY_1); // Pushto
        put("pt", CardinalityFamily.FAMILY_31); // Portuguese
        put("rm", CardinalityFamily.FAMILY_1); // Romansh
        put("ro", CardinalityFamily.FAMILY_14); // Romanian
        put("rof", CardinalityFamily.FAMILY_1); // Rombo
        put("root", CardinalityFamily.FAMILY_2); // Root
        put("ru", CardinalityFamily.FAMILY_15); // Russian
        put("rwk", CardinalityFamily.FAMILY_1); // Rwa
        put("sah", CardinalityFamily.FAMILY_2); // Sakha
        put("saq", CardinalityFamily.FAMILY_1); // Samburu
        put("sdh", CardinalityFamily.FAMILY_1); // Southern Kurdish
        put("se", CardinalityFamily.FAMILY_6); // Northern Sami
        put("seh", CardinalityFamily.FAMILY_1); // Sena
        put("ses", CardinalityFamily.FAMILY_2); // Koyraboro Senni
        put("sg", CardinalityFamily.FAMILY_2); // Sango
        put("sh", CardinalityFamily.FAMILY_7); // Serbo-Croatian
        put("shi", CardinalityFamily.FAMILY_32); // Tachelhit
        put("si", CardinalityFamily.FAMILY_33); // Sinhalese
        put("sk", CardinalityFamily.FAMILY_10); // Slovak
        put("sl", CardinalityFamily.FAMILY_34); // Slovenian
        put("sma", CardinalityFamily.FAMILY_6); // Southern Sami
        put("smi", CardinalityFamily.FAMILY_6); // Sami
        put("smj", CardinalityFamily.FAMILY_6); // Lule Sami
        put("smn", CardinalityFamily.FAMILY_6); // Inari Sami
        put("sms", CardinalityFamily.FAMILY_6); // Skolt Sami
        put("sn", CardinalityFamily.FAMILY_1); // Shona
        put("so", CardinalityFamily.FAMILY_1); // Somali
        put("sq", CardinalityFamily.FAMILY_1); // Albanian
        put("sr", CardinalityFamily.FAMILY_7); // Serbian
        put("ss", CardinalityFamily.FAMILY_1); // Swati
        put("ssy", CardinalityFamily.FAMILY_1); // Saho
        put("st", CardinalityFamily.FAMILY_1); // Southern Sotho
        put("sv", CardinalityFamily.FAMILY_3); // Swedish
        put("sw", CardinalityFamily.FAMILY_3); // Swahili
        put("syr", CardinalityFamily.FAMILY_1); // Syriac
        put("ta", CardinalityFamily.FAMILY_1); // Tamil
        put("te", CardinalityFamily.FAMILY_1); // Telugu
        put("teo", CardinalityFamily.FAMILY_1); // Teso
        put("th", CardinalityFamily.FAMILY_2); // Thai
        put("ti", CardinalityFamily.FAMILY_4); // Tigrinya
        put("tig", CardinalityFamily.FAMILY_1); // Tigre
        put("tk", CardinalityFamily.FAMILY_1); // Turkmen
        put("tl", CardinalityFamily.FAMILY_12); // Tagalog
        put("tn", CardinalityFamily.FAMILY_1); // Tswana
        put("to", CardinalityFamily.FAMILY_2); // Tongan
        put("tr", CardinalityFamily.FAMILY_1); // Turkish
        put("ts", CardinalityFamily.FAMILY_1); // Tsonga
        put("tzm", CardinalityFamily.FAMILY_35); // Central Atlas Tamazight
        put("ug", CardinalityFamily.FAMILY_1); // Uighur
        put("uk", CardinalityFamily.FAMILY_15); // Ukrainian
        put("ur", CardinalityFamily.FAMILY_3); // Urdu
        put("uz", CardinalityFamily.FAMILY_1); // Uzbek
        put("ve", CardinalityFamily.FAMILY_1); // Venda
        put("vi", CardinalityFamily.FAMILY_2); // Vietnamese
        put("vo", CardinalityFamily.FAMILY_1); // Volapük
        put("vun", CardinalityFamily.FAMILY_1); // Vunjo
        put("wa", CardinalityFamily.FAMILY_4); // Walloon
        put("wae", CardinalityFamily.FAMILY_1); // Walser
        put("wo", CardinalityFamily.FAMILY_2); // Wolof
        put("xh", CardinalityFamily.FAMILY_1); // Xhosa
        put("xog", CardinalityFamily.FAMILY_1); // Soga
        put("yi", CardinalityFamily.FAMILY_3); // Yiddish
        put("yo", CardinalityFamily.FAMILY_2); // Yoruba
        put("yue", CardinalityFamily.FAMILY_2); // Cantonese
        put("zh", CardinalityFamily.FAMILY_2); // Mandarin Chinese
        put("zu", CardinalityFamily.FAMILY_5); // Zulu
      }});

      // Language codes are in English - force collation for sorting
      SortedSet<String> supportedLanguageCodes = new TreeSet<>(Collator.getInstance(Locale.ENGLISH));
      supportedLanguageCodes.addAll(CARDINALITY_FAMILIES_BY_LANGUAGE_CODE.keySet());

      SUPPORTED_LANGUAGE_CODES = Collections.unmodifiableSortedSet(supportedLanguageCodes);
    }

    /**
     * Gets the cardinality-determining function for this cardinality family.
     * <p>
     * The function takes a numeric value as input and returns the appropriate cardinal form.
     * <p>
     * The function's input must not be null and its output is guaranteed non-null.
     *
     * @return the cardinality-determining function for this cardinality family, not null
     */
    @Nonnull
    Function<BigDecimal, Cardinality> getCardinalityFunction() {
      return cardinalityFunction;
    }

    /**
     * Gets the cardinalities supported by this cardinality family.
     * <p>
     * There will always be at least one value - {@link Cardinality#OTHER} - in the set.
     * <p>
     * The set's values are sorted by the natural ordering of the {@link Cardinality} enumeration.
     *
     * @return the cardinalities supported by this cardinality family, not null
     */
    @Nonnull
    SortedSet<Cardinality> getSupportedCardinalities() {
      return supportedCardinalities;
    }

    /**
     * Gets a mapping of cardinalities to example integer values for this cardinality family.
     * <p>
     * The map may be empty.
     * <p>
     * The map's keys are sorted by the natural ordering of the {@link Cardinality} enumeration.
     *
     * @return a mapping of cardinalities to example integer values, not null
     */
    @Nonnull
    SortedMap<Cardinality, Range<Integer>> getExampleIntegerValuesByCardinality() {
      return exampleIntegerValuesByCardinality;
    }

    /**
     * Gets a mapping of cardinalities to example decimal values for this cardinality family.
     * <p>
     * The map may be empty.
     * <p>
     * The map's keys are sorted by the natural ordering of the {@link Cardinality} enumeration.
     *
     * @return a mapping of cardinalities to example decimal values, not null
     */
    @Nonnull
    SortedMap<Cardinality, Range<BigDecimal>> getExampleDecimalValuesByCardinality() {
      return exampleDecimalValuesByCardinality;
    }

    /**
     * Gets the ISO 639 language codes for which cardinality operations are supported.
     * <p>
     * The set's values are ISO 639 codes and therefore sorted using English collation.
     *
     * @return the ISO 639 language codes for which cardinality operations are supported, not null
     */
    @Nonnull
    static SortedSet<String> getSupportedLanguageCodes() {
      return SUPPORTED_LANGUAGE_CODES;
    }

    /**
     * Gets an appropriate plural cardinality family for the given locale.
     *
     * @param locale the locale to check, not null
     * @return the appropriate plural cardinality family (if one exists) for the given locale, not null
     */
    @Nonnull
    static Optional<CardinalityFamily> cardinalityFamilyForLocale(@Nonnull Locale locale) {
      requireNonNull(locale);

      String language = LocaleUtils.normalizedLanguage(locale).orElse(null);
      String country = locale.getCountry();

      CardinalityFamily cardinalityFamily = null;

      if (language != null && country != null)
        cardinalityFamily = CARDINALITY_FAMILIES_BY_LANGUAGE_CODE.get(format("%s-%s", language, country));

      if (cardinalityFamily != null)
        return Optional.of(cardinalityFamily);

      if (language != null)
        cardinalityFamily = CARDINALITY_FAMILIES_BY_LANGUAGE_CODE.get(language);

      return Optional.ofNullable(cardinalityFamily);
    }
  }


  enum CardinalityRangeFamily {
    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Akan (ak)</li>
     * <li>Najdi Arabic (ars)</li>
     * <li>Assamese (as)</li>
     * <li>Asu (asa)</li>
     * <li>Asturian (ast)</li>
     * <li>Bemba (bem)</li>
     * <li>Bena (bez)</li>
     * <li>Bihari (bh)</li>
     * <li>Bambara (bm)</li>
     * <li>Tibetan (bo)</li>
     * <li>Breton (br)</li>
     * <li>Bodo (brx)</li>
     * <li>Chechen (ce)</li>
     * <li>Chiga (cgg)</li>
     * <li>Cherokee (chr)</li>
     * <li>Central Kurdish (ckb)</li>
     * <li>Lower Sorbian (dsb)</li>
     * <li>Divehi (dv)</li>
     * <li>Dzongkha (dz)</li>
     * <li>Ewe (ee)</li>
     * <li>Esperanto (eo)</li>
     * <li>Fulah (ff)</li>
     * <li>Faroese (fo)</li>
     * <li>Friulian (fur)</li>
     * <li>Western Frisian (fy)</li>
     * <li>Scottish Gaelic (gd)</li>
     * <li>Gun (guw)</li>
     * <li>Manx (gv)</li>
     * <li>Hausa (ha)</li>
     * <li>Hawaiian (haw)</li>
     * <li>Upper Sorbian (hsb)</li>
     * <li>Igbo (ig)</li>
     * <li>Sichuan Yi (ii)</li>
     * <li>Inuktitut (iu)</li>
     * <li>Lojban (jbo)</li>
     * <li>Ngomba (jgo)</li>
     * <li>Machame (jmc)</li>
     * <li>Javanese (jv)</li>
     * <li>Javanese (jw)</li>
     * <li>Kabyle (kab)</li>
     * <li>Jju (kaj)</li>
     * <li>Tyap (kcg)</li>
     * <li>Makonde (kde)</li>
     * <li>Kabuverdianu (kea)</li>
     * <li>Kako (kkj)</li>
     * <li>Greenlandic (kl)</li>
     * <li>Kashmiri (ks)</li>
     * <li>Shambala (ksb)</li>
     * <li>Colognian (ksh)</li>
     * <li>Kurdish (ku)</li>
     * <li>Cornish (kw)</li>
     * <li>Langi (lag)</li>
     * <li>Luxembourgish (lb)</li>
     * <li>Ganda (lg)</li>
     * <li>Lakota (lkt)</li>
     * <li>Lingala (ln)</li>
     * <li>Masai (mas)</li>
     * <li>Malagasy (mg)</li>
     * <li>Metaʼ (mgo)</li>
     * <li>Moldovan (mo)</li>
     * <li>Maltese (mt)</li>
     * <li>Nahuatl (nah)</li>
     * <li>Nama (naq)</li>
     * <li>North Ndebele (nd)</li>
     * <li>Norwegian Nynorsk (nn)</li>
     * <li>Ngiemboon (nnh)</li>
     * <li>Norwegian (no)</li>
     * <li>N’Ko (nqo)</li>
     * <li>South Ndebele (nr)</li>
     * <li>Northern Sotho (nso)</li>
     * <li>Nyanja (ny)</li>
     * <li>Nyankole (nyn)</li>
     * <li>Oromo (om)</li>
     * <li>Odia (or)</li>
     * <li>Ossetian (os)</li>
     * <li>Papiamento (pap)</li>
     * <li>Prussian (prg)</li>
     * <li>Pushto (ps)</li>
     * <li>Romansh (rm)</li>
     * <li>Rombo (rof)</li>
     * <li>Root (root)</li>
     * <li>Rwa (rwk)</li>
     * <li>Sakha (sah)</li>
     * <li>Samburu (saq)</li>
     * <li>Southern Kurdish (sdh)</li>
     * <li>Northern Sami (se)</li>
     * <li>Sena (seh)</li>
     * <li>Koyraboro Senni (ses)</li>
     * <li>Sango (sg)</li>
     * <li>Serbo-Croatian (sh)</li>
     * <li>Tachelhit (shi)</li>
     * <li>Southern Sami (sma)</li>
     * <li>Sami (smi)</li>
     * <li>Lule Sami (smj)</li>
     * <li>Inari Sami (smn)</li>
     * <li>Skolt Sami (sms)</li>
     * <li>Shona (sn)</li>
     * <li>Somali (so)</li>
     * <li>Swati (ss)</li>
     * <li>Saho (ssy)</li>
     * <li>Southern Sotho (st)</li>
     * <li>Syriac (syr)</li>
     * <li>Teso (teo)</li>
     * <li>Tigrinya (ti)</li>
     * <li>Tigre (tig)</li>
     * <li>Turkmen (tk)</li>
     * <li>Tagalog (tl)</li>
     * <li>Tswana (tn)</li>
     * <li>Tongan (to)</li>
     * <li>Tsonga (ts)</li>
     * <li>Central Atlas Tamazight (tzm)</li>
     * <li>Venda (ve)</li>
     * <li>Volapük (vo)</li>
     * <li>Vunjo (vun)</li>
     * <li>Walloon (wa)</li>
     * <li>Walser (wae)</li>
     * <li>Wolof (wo)</li>
     * <li>Xhosa (xh)</li>
     * <li>Soga (xog)</li>
     * <li>Yiddish (yi)</li>
     * <li>Yoruba (yo)</li>
     * </ul>
     */
    FAMILY_1(
        // There are no cardinality ranges for this family
        Collections.emptySortedMap()
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Azeri (az)</li>
     * <li>German (de)</li>
     * <li>Greek (el)</li>
     * <li>Galician (gl)</li>
     * <li>Swiss German (gsw)</li>
     * <li>Hungarian (hu)</li>
     * <li>Italian (it)</li>
     * <li>Kazakh (kk)</li>
     * <li>Kirghiz (ky)</li>
     * <li>Malayalam (ml)</li>
     * <li>Mongolian (mn)</li>
     * <li>Nepali (ne)</li>
     * <li>Dutch (nl)</li>
     * <li>Albanian (sq)</li>
     * <li>Swahili (sw)</li>
     * <li>Tamil (ta)</li>
     * <li>Telugu (te)</li>
     * <li>Turkish (tr)</li>
     * <li>Uighur (ug)</li>
     * <li>Uzbek (uz)</li>
     * </ul>
     */
    FAMILY_2(
        Maps.sortedMap(
            MapEntry.of(CardinalityRange.of(ONE, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(OTHER, ONE), ONE),
            MapEntry.of(CardinalityRange.of(OTHER, OTHER), OTHER)
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Afrikaans (af)</li>
     * <li>Bulgarian (bg)</li>
     * <li>Catalan (ca)</li>
     * <li>English (en)</li>
     * <li>Spanish (es)</li>
     * <li>Estonian (et)</li>
     * <li>Basque (eu)</li>
     * <li>Finnish (fi)</li>
     * <li>Norwegian Bokmål (nb)</li>
     * <li>Swedish (sv)</li>
     * <li>Urdu (ur)</li>
     * </ul>
     */
    FAMILY_3(
        Maps.sortedMap(
            MapEntry.of(CardinalityRange.of(ONE, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(OTHER, ONE), OTHER),
            MapEntry.of(CardinalityRange.of(OTHER, OTHER), OTHER)
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Indonesian (id)</li>
     * <li>Japanese (ja)</li>
     * <li>Khmer (km)</li>
     * <li>Korean (ko)</li>
     * <li>Lao (lo)</li>
     * <li>Malay (ms)</li>
     * <li>Burmese (my)</li>
     * <li>Thai (th)</li>
     * <li>Vietnamese (vi)</li>
     * <li>Cantonese (yue)</li>
     * <li>Mandarin Chinese (zh)</li>
     * </ul>
     */
    FAMILY_4(
        Maps.sortedMap(
            MapEntry.of(CardinalityRange.of(OTHER, OTHER), OTHER)
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Amharic (am)</li>
     * <li>Bangla (bn)</li>
     * <li>French (fr)</li>
     * <li>Gujarati (gu)</li>
     * <li>Hindi (hi)</li>
     * <li>Armenian (hy)</li>
     * <li>Kannada (kn)</li>
     * <li>Marathi (mr)</li>
     * <li>Zulu (zu)</li>
     * </ul>
     */
    FAMILY_5(
        Maps.sortedMap(
            MapEntry.of(CardinalityRange.of(ONE, ONE), ONE),
            MapEntry.of(CardinalityRange.of(ONE, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(OTHER, OTHER), OTHER)
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Danish (da)</li>
     * <li>Filipino (fil)</li>
     * <li>Icelandic (is)</li>
     * <li>Punjabi (pa)</li>
     * <li>Portuguese (pt)</li>
     * </ul>
     */
    FAMILY_6(
        Maps.sortedMap(
            MapEntry.of(CardinalityRange.of(ONE, ONE), ONE),
            MapEntry.of(CardinalityRange.of(ONE, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(OTHER, ONE), ONE),
            MapEntry.of(CardinalityRange.of(OTHER, OTHER), OTHER)
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Belarusian (be)</li>
     * <li>Lithuanian (lt)</li>
     * <li>Russian (ru)</li>
     * <li>Ukrainian (uk)</li>
     * </ul>
     */
    FAMILY_7(
        Maps.sortedMap(
            MapEntry.of(CardinalityRange.of(ONE, ONE), ONE),
            MapEntry.of(CardinalityRange.of(ONE, FEW), FEW),
            MapEntry.of(CardinalityRange.of(ONE, MANY), MANY),
            MapEntry.of(CardinalityRange.of(ONE, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(FEW, ONE), ONE),
            MapEntry.of(CardinalityRange.of(FEW, FEW), FEW),
            MapEntry.of(CardinalityRange.of(FEW, MANY), MANY),
            MapEntry.of(CardinalityRange.of(FEW, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(MANY, ONE), ONE),
            MapEntry.of(CardinalityRange.of(MANY, FEW), FEW),
            MapEntry.of(CardinalityRange.of(MANY, MANY), MANY),
            MapEntry.of(CardinalityRange.of(MANY, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(OTHER, ONE), ONE),
            MapEntry.of(CardinalityRange.of(OTHER, FEW), FEW),
            MapEntry.of(CardinalityRange.of(OTHER, MANY), MANY),
            MapEntry.of(CardinalityRange.of(OTHER, OTHER), OTHER)
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Bosnian (bs)</li>
     * <li>Croatian (hr)</li>
     * <li>Serbian (sr)</li>
     * </ul>
     */
    FAMILY_8(
        Maps.sortedMap(
            MapEntry.of(CardinalityRange.of(ONE, ONE), ONE),
            MapEntry.of(CardinalityRange.of(ONE, FEW), FEW),
            MapEntry.of(CardinalityRange.of(ONE, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(FEW, ONE), ONE),
            MapEntry.of(CardinalityRange.of(FEW, FEW), FEW),
            MapEntry.of(CardinalityRange.of(FEW, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(OTHER, ONE), ONE),
            MapEntry.of(CardinalityRange.of(OTHER, FEW), FEW),
            MapEntry.of(CardinalityRange.of(OTHER, OTHER), OTHER)
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Czech (cs)</li>
     * <li>Polish (pl)</li>
     * <li>Slovak (sk)</li>
     * </ul>
     */
    FAMILY_9(
        Maps.sortedMap(
            MapEntry.of(CardinalityRange.of(ONE, FEW), FEW),
            MapEntry.of(CardinalityRange.of(ONE, MANY), MANY),
            MapEntry.of(CardinalityRange.of(ONE, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(FEW, FEW), FEW),
            MapEntry.of(CardinalityRange.of(FEW, MANY), MANY),
            MapEntry.of(CardinalityRange.of(FEW, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(MANY, ONE), ONE),
            MapEntry.of(CardinalityRange.of(MANY, FEW), FEW),
            MapEntry.of(CardinalityRange.of(MANY, MANY), MANY),
            MapEntry.of(CardinalityRange.of(MANY, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(OTHER, ONE), ONE),
            MapEntry.of(CardinalityRange.of(OTHER, FEW), FEW),
            MapEntry.of(CardinalityRange.of(OTHER, MANY), MANY),
            MapEntry.of(CardinalityRange.of(OTHER, OTHER), OTHER)
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Arabic (ar)</li>
     * </ul>
     */
    FAMILY_10(
        Maps.sortedMap(
            MapEntry.of(CardinalityRange.of(ZERO, ONE), ZERO),
            MapEntry.of(CardinalityRange.of(ZERO, TWO), ZERO),
            MapEntry.of(CardinalityRange.of(ZERO, FEW), FEW),
            MapEntry.of(CardinalityRange.of(ZERO, MANY), MANY),
            MapEntry.of(CardinalityRange.of(ZERO, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(ONE, TWO), OTHER),
            MapEntry.of(CardinalityRange.of(ONE, FEW), FEW),
            MapEntry.of(CardinalityRange.of(ONE, MANY), MANY),
            MapEntry.of(CardinalityRange.of(ONE, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(TWO, FEW), FEW),
            MapEntry.of(CardinalityRange.of(TWO, MANY), MANY),
            MapEntry.of(CardinalityRange.of(TWO, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(FEW, FEW), FEW),
            MapEntry.of(CardinalityRange.of(FEW, MANY), MANY),
            MapEntry.of(CardinalityRange.of(FEW, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(MANY, FEW), FEW),
            MapEntry.of(CardinalityRange.of(MANY, MANY), MANY),
            MapEntry.of(CardinalityRange.of(MANY, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(OTHER, ONE), OTHER),
            MapEntry.of(CardinalityRange.of(OTHER, TWO), OTHER),
            MapEntry.of(CardinalityRange.of(OTHER, FEW), FEW),
            MapEntry.of(CardinalityRange.of(OTHER, MANY), MANY),
            MapEntry.of(CardinalityRange.of(OTHER, OTHER), OTHER)
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Welsh (cy)</li>
     * </ul>
     */
    FAMILY_11(
        Maps.sortedMap(
            MapEntry.of(CardinalityRange.of(ZERO, ONE), ONE),
            MapEntry.of(CardinalityRange.of(ZERO, TWO), TWO),
            MapEntry.of(CardinalityRange.of(ZERO, FEW), FEW),
            MapEntry.of(CardinalityRange.of(ZERO, MANY), MANY),
            MapEntry.of(CardinalityRange.of(ZERO, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(ONE, TWO), TWO),
            MapEntry.of(CardinalityRange.of(ONE, FEW), FEW),
            MapEntry.of(CardinalityRange.of(ONE, MANY), MANY),
            MapEntry.of(CardinalityRange.of(ONE, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(TWO, FEW), FEW),
            MapEntry.of(CardinalityRange.of(TWO, MANY), MANY),
            MapEntry.of(CardinalityRange.of(TWO, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(FEW, MANY), MANY),
            MapEntry.of(CardinalityRange.of(FEW, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(MANY, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(OTHER, ONE), ONE),
            MapEntry.of(CardinalityRange.of(OTHER, TWO), TWO),
            MapEntry.of(CardinalityRange.of(OTHER, FEW), FEW),
            MapEntry.of(CardinalityRange.of(OTHER, MANY), MANY),
            MapEntry.of(CardinalityRange.of(OTHER, OTHER), OTHER)
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Persian (fa)</li>
     * </ul>
     */
    FAMILY_12(
        Maps.sortedMap(
            MapEntry.of(CardinalityRange.of(ONE, ONE), OTHER),
            MapEntry.of(CardinalityRange.of(ONE, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(OTHER, OTHER), OTHER)
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Irish (ga)</li>
     * </ul>
     */
    FAMILY_13(
        Maps.sortedMap(
            MapEntry.of(CardinalityRange.of(ONE, TWO), TWO),
            MapEntry.of(CardinalityRange.of(ONE, FEW), FEW),
            MapEntry.of(CardinalityRange.of(ONE, MANY), MANY),
            MapEntry.of(CardinalityRange.of(ONE, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(TWO, FEW), FEW),
            MapEntry.of(CardinalityRange.of(TWO, MANY), MANY),
            MapEntry.of(CardinalityRange.of(TWO, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(FEW, FEW), FEW),
            MapEntry.of(CardinalityRange.of(FEW, MANY), MANY),
            MapEntry.of(CardinalityRange.of(FEW, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(MANY, MANY), MANY),
            MapEntry.of(CardinalityRange.of(MANY, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(OTHER, ONE), ONE),
            MapEntry.of(CardinalityRange.of(OTHER, TWO), TWO),
            MapEntry.of(CardinalityRange.of(OTHER, FEW), FEW),
            MapEntry.of(CardinalityRange.of(OTHER, MANY), MANY),
            MapEntry.of(CardinalityRange.of(OTHER, OTHER), OTHER)
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Hebrew (he)</li>
     * </ul>
     */
    FAMILY_14(
        Maps.sortedMap(
            MapEntry.of(CardinalityRange.of(ONE, TWO), OTHER),
            MapEntry.of(CardinalityRange.of(ONE, MANY), MANY),
            MapEntry.of(CardinalityRange.of(ONE, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(TWO, MANY), OTHER),
            MapEntry.of(CardinalityRange.of(TWO, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(MANY, MANY), MANY),
            MapEntry.of(CardinalityRange.of(MANY, OTHER), MANY),
            MapEntry.of(CardinalityRange.of(OTHER, ONE), OTHER),
            MapEntry.of(CardinalityRange.of(OTHER, TWO), OTHER),
            MapEntry.of(CardinalityRange.of(OTHER, MANY), MANY),
            MapEntry.of(CardinalityRange.of(OTHER, OTHER), OTHER)
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Georgian (ka)</li>
     * </ul>
     */
    FAMILY_15(
        Maps.sortedMap(
            MapEntry.of(CardinalityRange.of(ONE, OTHER), ONE),
            MapEntry.of(CardinalityRange.of(OTHER, ONE), OTHER),
            MapEntry.of(CardinalityRange.of(OTHER, OTHER), OTHER)
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Latvian (lv)</li>
     * </ul>
     */
    FAMILY_16(
        Maps.sortedMap(
            MapEntry.of(CardinalityRange.of(ZERO, ZERO), OTHER),
            MapEntry.of(CardinalityRange.of(ZERO, ONE), ONE),
            MapEntry.of(CardinalityRange.of(ZERO, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(ONE, ZERO), OTHER),
            MapEntry.of(CardinalityRange.of(ONE, ONE), ONE),
            MapEntry.of(CardinalityRange.of(ONE, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(OTHER, ZERO), OTHER),
            MapEntry.of(CardinalityRange.of(OTHER, ONE), ONE),
            MapEntry.of(CardinalityRange.of(OTHER, OTHER), OTHER)
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Macedonian (mk)</li>
     * </ul>
     */
    FAMILY_17(
        Maps.sortedMap(
            MapEntry.of(CardinalityRange.of(ONE, ONE), OTHER),
            MapEntry.of(CardinalityRange.of(ONE, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(OTHER, ONE), OTHER),
            MapEntry.of(CardinalityRange.of(OTHER, OTHER), OTHER)
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Romanian (ro)</li>
     * </ul>
     */
    FAMILY_18(
        Maps.sortedMap(
            MapEntry.of(CardinalityRange.of(ONE, FEW), FEW),
            MapEntry.of(CardinalityRange.of(ONE, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(FEW, ONE), FEW),
            MapEntry.of(CardinalityRange.of(FEW, FEW), FEW),
            MapEntry.of(CardinalityRange.of(FEW, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(OTHER, FEW), FEW),
            MapEntry.of(CardinalityRange.of(OTHER, OTHER), OTHER)
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Sinhalese (si)</li>
     * </ul>
     */
    FAMILY_19(
        Maps.sortedMap(
            MapEntry.of(CardinalityRange.of(ONE, ONE), ONE),
            MapEntry.of(CardinalityRange.of(ONE, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(OTHER, ONE), OTHER),
            MapEntry.of(CardinalityRange.of(OTHER, OTHER), OTHER)
        )
    ),

    /**
     * Languages Include:
     * <p>
     * <ul>
     * <li>Slovenian (sl)</li>
     * </ul>
     */
    FAMILY_20(
        Maps.sortedMap(
            MapEntry.of(CardinalityRange.of(ONE, ONE), FEW),
            MapEntry.of(CardinalityRange.of(ONE, TWO), TWO),
            MapEntry.of(CardinalityRange.of(ONE, FEW), FEW),
            MapEntry.of(CardinalityRange.of(ONE, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(TWO, ONE), FEW),
            MapEntry.of(CardinalityRange.of(TWO, TWO), TWO),
            MapEntry.of(CardinalityRange.of(TWO, FEW), FEW),
            MapEntry.of(CardinalityRange.of(TWO, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(FEW, ONE), FEW),
            MapEntry.of(CardinalityRange.of(FEW, TWO), TWO),
            MapEntry.of(CardinalityRange.of(FEW, FEW), FEW),
            MapEntry.of(CardinalityRange.of(FEW, OTHER), OTHER),
            MapEntry.of(CardinalityRange.of(OTHER, ONE), FEW),
            MapEntry.of(CardinalityRange.of(OTHER, TWO), TWO),
            MapEntry.of(CardinalityRange.of(OTHER, FEW), FEW),
            MapEntry.of(CardinalityRange.of(OTHER, OTHER), OTHER)
        )
    );

    @Nonnull
    private static final Map<String, CardinalityRangeFamily> CARDINALITY_RANGE_FAMILIES_BY_LANGUAGE_CODE;

    @Nonnull
    private final SortedMap<CardinalityRange, Cardinality> cardinalitiesByCardinalityRange;

    static {
      CARDINALITY_RANGE_FAMILIES_BY_LANGUAGE_CODE = Collections.unmodifiableMap(new HashMap<String, CardinalityRangeFamily>() {
        {
          put("af", CardinalityRangeFamily.FAMILY_3); // Afrikaans
          put("ak", CardinalityRangeFamily.FAMILY_1); // Akan
          put("am", CardinalityRangeFamily.FAMILY_5); // Amharic
          put("ar", CardinalityRangeFamily.FAMILY_10); // Arabic
          put("ars", CardinalityRangeFamily.FAMILY_1); // Najdi Arabic
          put("as", CardinalityRangeFamily.FAMILY_1); // Assamese
          put("asa", CardinalityRangeFamily.FAMILY_1); // Asu
          put("ast", CardinalityRangeFamily.FAMILY_1); // Asturian
          put("az", CardinalityRangeFamily.FAMILY_2); // Azeri
          put("be", CardinalityRangeFamily.FAMILY_7); // Belarusian
          put("bem", CardinalityRangeFamily.FAMILY_1); // Bemba
          put("bez", CardinalityRangeFamily.FAMILY_1); // Bena
          put("bg", CardinalityRangeFamily.FAMILY_3); // Bulgarian
          put("bh", CardinalityRangeFamily.FAMILY_1); // Bihari
          put("bm", CardinalityRangeFamily.FAMILY_1); // Bambara
          put("bn", CardinalityRangeFamily.FAMILY_5); // Bangla
          put("bo", CardinalityRangeFamily.FAMILY_1); // Tibetan
          put("br", CardinalityRangeFamily.FAMILY_1); // Breton
          put("brx", CardinalityRangeFamily.FAMILY_1); // Bodo
          put("bs", CardinalityRangeFamily.FAMILY_8); // Bosnian
          put("ca", CardinalityRangeFamily.FAMILY_3); // Catalan
          put("ce", CardinalityRangeFamily.FAMILY_1); // Chechen
          put("cgg", CardinalityRangeFamily.FAMILY_1); // Chiga
          put("chr", CardinalityRangeFamily.FAMILY_1); // Cherokee
          put("ckb", CardinalityRangeFamily.FAMILY_1); // Central Kurdish
          put("cs", CardinalityRangeFamily.FAMILY_9); // Czech
          put("cy", CardinalityRangeFamily.FAMILY_11); // Welsh
          put("da", CardinalityRangeFamily.FAMILY_6); // Danish
          put("de", CardinalityRangeFamily.FAMILY_2); // German
          put("dsb", CardinalityRangeFamily.FAMILY_1); // Lower Sorbian
          put("dv", CardinalityRangeFamily.FAMILY_1); // Divehi
          put("dz", CardinalityRangeFamily.FAMILY_1); // Dzongkha
          put("ee", CardinalityRangeFamily.FAMILY_1); // Ewe
          put("el", CardinalityRangeFamily.FAMILY_2); // Greek
          put("en", CardinalityRangeFamily.FAMILY_3); // English
          put("eo", CardinalityRangeFamily.FAMILY_1); // Esperanto
          put("es", CardinalityRangeFamily.FAMILY_3); // Spanish
          put("et", CardinalityRangeFamily.FAMILY_3); // Estonian
          put("eu", CardinalityRangeFamily.FAMILY_3); // Basque
          put("fa", CardinalityRangeFamily.FAMILY_12); // Persian
          put("ff", CardinalityRangeFamily.FAMILY_1); // Fulah
          put("fi", CardinalityRangeFamily.FAMILY_3); // Finnish
          put("fil", CardinalityRangeFamily.FAMILY_6); // Filipino
          put("fo", CardinalityRangeFamily.FAMILY_1); // Faroese
          put("fr", CardinalityRangeFamily.FAMILY_5); // French
          put("fur", CardinalityRangeFamily.FAMILY_1); // Friulian
          put("fy", CardinalityRangeFamily.FAMILY_1); // Western Frisian
          put("ga", CardinalityRangeFamily.FAMILY_13); // Irish
          put("gd", CardinalityRangeFamily.FAMILY_1); // Scottish Gaelic
          put("gl", CardinalityRangeFamily.FAMILY_2); // Galician
          put("gsw", CardinalityRangeFamily.FAMILY_2); // Swiss German
          put("gu", CardinalityRangeFamily.FAMILY_5); // Gujarati
          put("guw", CardinalityRangeFamily.FAMILY_1); // Gun
          put("gv", CardinalityRangeFamily.FAMILY_1); // Manx
          put("ha", CardinalityRangeFamily.FAMILY_1); // Hausa
          put("haw", CardinalityRangeFamily.FAMILY_1); // Hawaiian
          put("he", CardinalityRangeFamily.FAMILY_14); // Hebrew
          put("hi", CardinalityRangeFamily.FAMILY_5); // Hindi
          put("hr", CardinalityRangeFamily.FAMILY_8); // Croatian
          put("hsb", CardinalityRangeFamily.FAMILY_1); // Upper Sorbian
          put("hu", CardinalityRangeFamily.FAMILY_2); // Hungarian
          put("hy", CardinalityRangeFamily.FAMILY_5); // Armenian
          put("id", CardinalityRangeFamily.FAMILY_4); // Indonesian
          put("ig", CardinalityRangeFamily.FAMILY_1); // Igbo
          put("ii", CardinalityRangeFamily.FAMILY_1); // Sichuan Yi
          put("is", CardinalityRangeFamily.FAMILY_6); // Icelandic
          put("it", CardinalityRangeFamily.FAMILY_2); // Italian
          put("iu", CardinalityRangeFamily.FAMILY_1); // Inuktitut
          put("ja", CardinalityRangeFamily.FAMILY_4); // Japanese
          put("jbo", CardinalityRangeFamily.FAMILY_1); // Lojban
          put("jgo", CardinalityRangeFamily.FAMILY_1); // Ngomba
          put("jmc", CardinalityRangeFamily.FAMILY_1); // Machame
          put("jv", CardinalityRangeFamily.FAMILY_1); // Javanese
          put("jw", CardinalityRangeFamily.FAMILY_1); // Javanese
          put("ka", CardinalityRangeFamily.FAMILY_15); // Georgian
          put("kab", CardinalityRangeFamily.FAMILY_1); // Kabyle
          put("kaj", CardinalityRangeFamily.FAMILY_1); // Jju
          put("kcg", CardinalityRangeFamily.FAMILY_1); // Tyap
          put("kde", CardinalityRangeFamily.FAMILY_1); // Makonde
          put("kea", CardinalityRangeFamily.FAMILY_1); // Kabuverdianu
          put("kk", CardinalityRangeFamily.FAMILY_2); // Kazakh
          put("kkj", CardinalityRangeFamily.FAMILY_1); // Kako
          put("kl", CardinalityRangeFamily.FAMILY_1); // Greenlandic
          put("km", CardinalityRangeFamily.FAMILY_4); // Khmer
          put("kn", CardinalityRangeFamily.FAMILY_5); // Kannada
          put("ko", CardinalityRangeFamily.FAMILY_4); // Korean
          put("ks", CardinalityRangeFamily.FAMILY_1); // Kashmiri
          put("ksb", CardinalityRangeFamily.FAMILY_1); // Shambala
          put("ksh", CardinalityRangeFamily.FAMILY_1); // Colognian
          put("ku", CardinalityRangeFamily.FAMILY_1); // Kurdish
          put("kw", CardinalityRangeFamily.FAMILY_1); // Cornish
          put("ky", CardinalityRangeFamily.FAMILY_2); // Kirghiz
          put("lag", CardinalityRangeFamily.FAMILY_1); // Langi
          put("lb", CardinalityRangeFamily.FAMILY_1); // Luxembourgish
          put("lg", CardinalityRangeFamily.FAMILY_1); // Ganda
          put("lkt", CardinalityRangeFamily.FAMILY_1); // Lakota
          put("ln", CardinalityRangeFamily.FAMILY_1); // Lingala
          put("lo", CardinalityRangeFamily.FAMILY_4); // Lao
          put("lt", CardinalityRangeFamily.FAMILY_7); // Lithuanian
          put("lv", CardinalityRangeFamily.FAMILY_16); // Latvian
          put("mas", CardinalityRangeFamily.FAMILY_1); // Masai
          put("mg", CardinalityRangeFamily.FAMILY_1); // Malagasy
          put("mgo", CardinalityRangeFamily.FAMILY_1); // Metaʼ
          put("mk", CardinalityRangeFamily.FAMILY_17); // Macedonian
          put("ml", CardinalityRangeFamily.FAMILY_2); // Malayalam
          put("mn", CardinalityRangeFamily.FAMILY_2); // Mongolian
          put("mo", CardinalityRangeFamily.FAMILY_1); // Moldovan
          put("mr", CardinalityRangeFamily.FAMILY_5); // Marathi
          put("ms", CardinalityRangeFamily.FAMILY_4); // Malay
          put("mt", CardinalityRangeFamily.FAMILY_1); // Maltese
          put("my", CardinalityRangeFamily.FAMILY_4); // Burmese
          put("nah", CardinalityRangeFamily.FAMILY_1); // Nahuatl
          put("naq", CardinalityRangeFamily.FAMILY_1); // Nama
          put("nb", CardinalityRangeFamily.FAMILY_3); // Norwegian Bokmål
          put("nd", CardinalityRangeFamily.FAMILY_1); // North Ndebele
          put("ne", CardinalityRangeFamily.FAMILY_2); // Nepali
          put("nl", CardinalityRangeFamily.FAMILY_2); // Dutch
          put("nn", CardinalityRangeFamily.FAMILY_1); // Norwegian Nynorsk
          put("nnh", CardinalityRangeFamily.FAMILY_1); // Ngiemboon
          put("no", CardinalityRangeFamily.FAMILY_1); // Norwegian
          put("nqo", CardinalityRangeFamily.FAMILY_1); // N’Ko
          put("nr", CardinalityRangeFamily.FAMILY_1); // South Ndebele
          put("nso", CardinalityRangeFamily.FAMILY_1); // Northern Sotho
          put("ny", CardinalityRangeFamily.FAMILY_1); // Nyanja
          put("nyn", CardinalityRangeFamily.FAMILY_1); // Nyankole
          put("om", CardinalityRangeFamily.FAMILY_1); // Oromo
          put("or", CardinalityRangeFamily.FAMILY_1); // Odia
          put("os", CardinalityRangeFamily.FAMILY_1); // Ossetian
          put("pa", CardinalityRangeFamily.FAMILY_6); // Punjabi
          put("pap", CardinalityRangeFamily.FAMILY_1); // Papiamento
          put("pl", CardinalityRangeFamily.FAMILY_9); // Polish
          put("prg", CardinalityRangeFamily.FAMILY_1); // Prussian
          put("ps", CardinalityRangeFamily.FAMILY_1); // Pushto
          put("pt", CardinalityRangeFamily.FAMILY_6); // Portuguese
          put("rm", CardinalityRangeFamily.FAMILY_1); // Romansh
          put("ro", CardinalityRangeFamily.FAMILY_18); // Romanian
          put("rof", CardinalityRangeFamily.FAMILY_1); // Rombo
          put("root", CardinalityRangeFamily.FAMILY_1); // Root
          put("ru", CardinalityRangeFamily.FAMILY_7); // Russian
          put("rwk", CardinalityRangeFamily.FAMILY_1); // Rwa
          put("sah", CardinalityRangeFamily.FAMILY_1); // Sakha
          put("saq", CardinalityRangeFamily.FAMILY_1); // Samburu
          put("sdh", CardinalityRangeFamily.FAMILY_1); // Southern Kurdish
          put("se", CardinalityRangeFamily.FAMILY_1); // Northern Sami
          put("seh", CardinalityRangeFamily.FAMILY_1); // Sena
          put("ses", CardinalityRangeFamily.FAMILY_1); // Koyraboro Senni
          put("sg", CardinalityRangeFamily.FAMILY_1); // Sango
          put("sh", CardinalityRangeFamily.FAMILY_1); // Serbo-Croatian
          put("shi", CardinalityRangeFamily.FAMILY_1); // Tachelhit
          put("si", CardinalityRangeFamily.FAMILY_19); // Sinhalese
          put("sk", CardinalityRangeFamily.FAMILY_9); // Slovak
          put("sl", CardinalityRangeFamily.FAMILY_20); // Slovenian
          put("sma", CardinalityRangeFamily.FAMILY_1); // Southern Sami
          put("smi", CardinalityRangeFamily.FAMILY_1); // Sami
          put("smj", CardinalityRangeFamily.FAMILY_1); // Lule Sami
          put("smn", CardinalityRangeFamily.FAMILY_1); // Inari Sami
          put("sms", CardinalityRangeFamily.FAMILY_1); // Skolt Sami
          put("sn", CardinalityRangeFamily.FAMILY_1); // Shona
          put("so", CardinalityRangeFamily.FAMILY_1); // Somali
          put("sq", CardinalityRangeFamily.FAMILY_2); // Albanian
          put("sr", CardinalityRangeFamily.FAMILY_8); // Serbian
          put("ss", CardinalityRangeFamily.FAMILY_1); // Swati
          put("ssy", CardinalityRangeFamily.FAMILY_1); // Saho
          put("st", CardinalityRangeFamily.FAMILY_1); // Southern Sotho
          put("sv", CardinalityRangeFamily.FAMILY_3); // Swedish
          put("sw", CardinalityRangeFamily.FAMILY_2); // Swahili
          put("syr", CardinalityRangeFamily.FAMILY_1); // Syriac
          put("ta", CardinalityRangeFamily.FAMILY_2); // Tamil
          put("te", CardinalityRangeFamily.FAMILY_2); // Telugu
          put("teo", CardinalityRangeFamily.FAMILY_1); // Teso
          put("th", CardinalityRangeFamily.FAMILY_4); // Thai
          put("ti", CardinalityRangeFamily.FAMILY_1); // Tigrinya
          put("tig", CardinalityRangeFamily.FAMILY_1); // Tigre
          put("tk", CardinalityRangeFamily.FAMILY_1); // Turkmen
          put("tl", CardinalityRangeFamily.FAMILY_1); // Tagalog
          put("tn", CardinalityRangeFamily.FAMILY_1); // Tswana
          put("to", CardinalityRangeFamily.FAMILY_1); // Tongan
          put("tr", CardinalityRangeFamily.FAMILY_2); // Turkish
          put("ts", CardinalityRangeFamily.FAMILY_1); // Tsonga
          put("tzm", CardinalityRangeFamily.FAMILY_1); // Central Atlas Tamazight
          put("ug", CardinalityRangeFamily.FAMILY_2); // Uighur
          put("uk", CardinalityRangeFamily.FAMILY_7); // Ukrainian
          put("ur", CardinalityRangeFamily.FAMILY_3); // Urdu
          put("uz", CardinalityRangeFamily.FAMILY_2); // Uzbek
          put("ve", CardinalityRangeFamily.FAMILY_1); // Venda
          put("vi", CardinalityRangeFamily.FAMILY_4); // Vietnamese
          put("vo", CardinalityRangeFamily.FAMILY_1); // Volapük
          put("vun", CardinalityRangeFamily.FAMILY_1); // Vunjo
          put("wa", CardinalityRangeFamily.FAMILY_1); // Walloon
          put("wae", CardinalityRangeFamily.FAMILY_1); // Walser
          put("wo", CardinalityRangeFamily.FAMILY_1); // Wolof
          put("xh", CardinalityRangeFamily.FAMILY_1); // Xhosa
          put("xog", CardinalityRangeFamily.FAMILY_1); // Soga
          put("yi", CardinalityRangeFamily.FAMILY_1); // Yiddish
          put("yo", CardinalityRangeFamily.FAMILY_1); // Yoruba
          put("yue", CardinalityRangeFamily.FAMILY_4); // Cantonese
          put("zh", CardinalityRangeFamily.FAMILY_4); // Mandarin Chinese
          put("zu", CardinalityRangeFamily.FAMILY_5); // Zulu
        }
      });
    }

    /**
     * Gets an appropriate plural cardinality range family for the given locale.
     *
     * @param locale the locale to check, not null
     * @return the appropriate plural cardinality range family (if one exists) for the given locale, not null
     */
    @Nonnull
    static Optional<CardinalityRangeFamily> cardinalityRangeFamilyForLocale(@Nonnull Locale locale) {
      requireNonNull(locale);

      String language = LocaleUtils.normalizedLanguage(locale).orElse(null);
      String country = locale.getCountry();

      CardinalityRangeFamily cardinalityRangeFamily = null;

      if (language != null && country != null)
        cardinalityRangeFamily = CARDINALITY_RANGE_FAMILIES_BY_LANGUAGE_CODE.get(format("%s-%s", language, country));

      if (cardinalityRangeFamily != null)
        return Optional.of(cardinalityRangeFamily);

      if (language != null)
        cardinalityRangeFamily = CARDINALITY_RANGE_FAMILIES_BY_LANGUAGE_CODE.get(language);

      return Optional.ofNullable(cardinalityRangeFamily);
    }

    /**
     * Constructs a cardinality range family.
     *
     * @param cardinalitiesByCardinalityRange a mapping of cardinalities to example integer values for this cardinality range family sorted by the natural ordering of {@link Cardinality}, not null
     */
    CardinalityRangeFamily(@Nonnull SortedMap<CardinalityRange, Cardinality> cardinalitiesByCardinalityRange) {
      this.cardinalitiesByCardinalityRange = cardinalitiesByCardinalityRange;
    }

    @Nonnull
    SortedMap<CardinalityRange, Cardinality> getCardinalitiesByCardinalityRange() {
      return cardinalitiesByCardinalityRange;
    }
  }
}

<|start_filename|>src/main/java/com/lokalized/NumberUtils.java<|end_filename|>
/*
 * Copyright 2017 Product Mog LLC.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.lokalized;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.annotation.concurrent.ThreadSafe;
import java.math.BigDecimal;
import java.math.BigInteger;

import static java.lang.String.format;
import static java.util.Objects.requireNonNull;

/**
 * Collection of utility methods for working with numbers.
 *
 * @author <a href="https://revetkn.com"><NAME></a>
 */
@ThreadSafe
class NumberUtils {
  private NumberUtils() {
    // Non-instantiable
  }

  /**
   * Determines the number of decimal places in the given number.
   * <p>
   * If the number is a {@link BigDecimal}, then the scale (which may include trailing zeroes) is returned.
   * <p>
   * Otherwise, the number of decimal places without trailing zeroes is returned.
   * <p>
   * For example:
   * <p>
   * <ul>
   * <li>{@code 2} has {@code 0} decimal places</li>
   * <li>{@code 2.1} has {@code 1} decimal place</li>
   * <li>{@code 2.150} has {@code 2} decimal places (if non-{@code BigDecimal}) and 3 decimal places (if {@code BigDecimal})</li>
   * </ul>
   *
   * @param number the number for which we are counting decimal places, not null
   * @return the number of decimal places, not null
   */
  @Nonnull
  static Integer numberOfDecimalPlaces(@Nonnull Number number) {
    requireNonNull(number);

    if (number instanceof BigDecimal) {
      // Can determine trailing zeroes in this case
      BigDecimal numberAsBigDecimal = (BigDecimal) number;
      return normalizedScale(numberAsBigDecimal);
    }

    // Cannot determine trailing zeroes in this case
    BigDecimal numberAsBigDecimal = toBigDecimal(number);
    return Math.max(0, normalizedScale(numberAsBigDecimal.stripTrailingZeros()));
  }

  /**
   * Determines the integer component of the given number.
   * <p>
   * For example:
   * <p>
   * <ul>
   * <li>{@code 2} has an integer component of {@code 2}</li>
   * <li>{@code 1.234} has an integer component of {@code 1}</li>
   * <li>{@code -1.5} has an integer component of {@code -1}</li>
   * <li>{@code .5} has an integer component of {@code 0}</li>
   * </ul>
   *
   * @param number the number for which we are determining the integer component, not null
   * @return the integer component, not null
   */
  @Nonnull
  static BigInteger integerComponent(@Nonnull Number number) {
    requireNonNull(number);

    if (number instanceof BigDecimal) {
      BigDecimal numberAsBigDecimal = (BigDecimal) number;
      return numberAsBigDecimal.toBigInteger();
    }

    return toBigDecimal(number).toBigInteger();
  }

  /**
   * Determines the fractional component of the given number.
   * <p>
   * If the number is a {@link BigDecimal}, then fractional value according to its scale (which may include trailing
   * zeroes) is returned.
   * <p>
   * Otherwise, the fractional value without trailing zeroes is returned.
   * <p>
   * For example:
   * <p>
   * <ul>
   * <li>{@code 2} has a fractional component of {@code 0}</li>
   * <li>{@code 1.234} has a fractional component of {@code 234}</li>
   * <li>{@code -1.5} has a fractional component of {@code 5}</li>
   * <li>{@code .5} has a fractional component of {@code 5}</li>
   * <li>{@code 2.150} has a fractional component of {@code 15} (if non-{@code BigDecimal}) and {@code 150} (if {@code BigDecimal})</li>
   * </ul>
   *
   * @param number the number for which we are determining the fractional component, not null
   * @return the fractional component, not null
   */
  @Nonnull
  static BigInteger fractionalComponent(@Nonnull Number number) {
    requireNonNull(number);

    BigDecimal numberAsBigDecimal = null;

    if (number instanceof BigDecimal)
      numberAsBigDecimal = (BigDecimal) number;
    else
      numberAsBigDecimal = toBigDecimal(number);

    return numberAsBigDecimal.remainder(BigDecimal.ONE)
        .movePointRight(normalizedScale(numberAsBigDecimal))
        .abs()
        .toBigInteger();
  }

  /**
   * Provides a {@code BigDecimal} representation of the given number.
   *
   * @param number the number to represent as a {@code BigDecimal}, not null
   * @return a {@code BigDecimal} representation of the given number, not null
   */
  @Nonnull
  static BigDecimal toBigDecimal(@Nonnull Number number) {
    requireNonNull(number);

    if (number instanceof BigDecimal)
      return (BigDecimal) number;

    return new BigDecimal(String.valueOf(number.doubleValue())).stripTrailingZeros();
  }

  /**
   * Are the given numbers equal?
   *
   * @param number1 the first number to compare, not null
   * @param number2 the second number to compare, not null
   * @return true if the numbers are equal, not null
   */
  @Nonnull
  static Boolean equal(@Nonnull BigDecimal number1, @Nonnull BigDecimal number2) {
    requireNonNull(number1);
    requireNonNull(number2);

    return number1.compareTo(number2) == 0;
  }

  /**
   * Are the given numbers not equal?
   *
   * @param number1 the first number to compare, not null
   * @param number2 the second number to compare, not null
   * @return true if the numbers are not equal, not null
   */
  @Nonnull
  static Boolean notEqual(@Nonnull BigDecimal number1, @Nonnull BigDecimal number2) {
    return !equal(number1, number2);
  }

  /**
   * Is the first number less than the second number?
   *
   * @param number1 the first number to compare, not null
   * @param number2 the second number to compare, not null
   * @return true if the first number is less than the second number, not null
   */
  @Nonnull
  static Boolean lessThan(@Nonnull BigDecimal number1, @Nonnull BigDecimal number2) {
    requireNonNull(number1);
    requireNonNull(number2);

    return number1.compareTo(number2) < 0;
  }

  /**
   * Is the first number greater than the second number?
   *
   * @param number1 the first number to compare, not null
   * @param number2 the second number to compare, not null
   * @return true if the first number is greater than the second number, not null
   */
  @Nonnull
  static Boolean greaterThan(@Nonnull BigDecimal number1, @Nonnull BigDecimal number2) {
    requireNonNull(number1);
    requireNonNull(number2);

    return number1.compareTo(number2) > 0;
  }

  /**
   * Does the given number fall within the range defined by its minimum and maximum (inclusive)?
   * <p>
   * <strong>Note:</strong> the scale of the number must match the scale of the range.  That is, a range with minimum {@code 2}
   * and maximum of {@code 5} would contain {@code 3} but <strong>not</strong> {@code 3.5}.
   *
   * @param number  the number to compare, not null
   * @param minimum the low end of the range (inclusive), not null
   * @param maximum the high end of the range (inclusive), not null
   * @return true if the number falls within the range, not null
   * @throws IllegalArgumentException if the minimum and maximum values have mismatched scales
   */
  @Nonnull
  static Boolean inRange(@Nonnull BigDecimal number, @Nonnull BigDecimal minimum, @Nonnull BigDecimal maximum) {
    requireNonNull(number);
    requireNonNull(minimum);
    requireNonNull(maximum);

    int minimumScale = normalizedScale(minimum);
    int maximumScale = normalizedScale(maximum);

    if (minimumScale != maximumScale)
      throw new IllegalArgumentException(format("Minimum scale %d does not match maximum scale %d", minimumScale, maximumScale));

    int numberScale = normalizedScale(number);

    if (numberScale != minimumScale)
      return false;

    return number.compareTo(minimum) >= 0 && number.compareTo(maximum) <= 0;
  }

  /**
   * Does the given number not fall within the range defined by its minimum and maximum (inclusive)?
   *
   * @param number  the number to compare, not null
   * @param minimum the low end of the range (inclusive), not null
   * @param maximum the high end of the range (inclusive), not null
   * @return true if the number falls within the range, not null
   */
  @Nonnull
  static Boolean notInRange(@Nonnull BigDecimal number, @Nonnull BigDecimal minimum, @Nonnull BigDecimal maximum) {
    return !inRange(number, minimum, maximum);
  }

  /**
   * Is the given number in the set of values?
   *
   * @param number the number to compare, not null
   * @param values the set of values against which to check, may be null
   * @return true if the number is in the set of values
   */
  @Nonnull
  static Boolean inSet(@Nonnull BigDecimal number, @Nullable BigDecimal... values) {
    requireNonNull(number);

    if (values == null || values.length == 0)
      return false;

    for (BigDecimal value : values)
      if (equal(number, value))
        return true;

    return false;
  }

  /**
   * Is the given number not in the set of values?
   *
   * @param number the number to compare, not null
   * @param values the set of values against which to check, may be null
   * @return true if the number is not in the set of values
   */
  @Nonnull
  static Boolean notInSet(@Nonnull BigDecimal number, @Nullable BigDecimal... values) {
    return !inSet(number, values);
  }

  /**
   * Are the given numbers equal?
   *
   * @param number1 the first number to compare, not null
   * @param number2 the second number to compare, not null
   * @return true if the numbers are equal, not null
   */
  @Nonnull
  static Boolean equal(@Nonnull BigInteger number1, @Nonnull BigInteger number2) {
    requireNonNull(number1);
    requireNonNull(number2);

    return number1.compareTo(number2) == 0;
  }

  /**
   * Are the given numbers not equal?
   *
   * @param number1 the first number to compare, not null
   * @param number2 the second number to compare, not null
   * @return true if the numbers are not equal, not null
   */
  @Nonnull
  static Boolean notEqual(@Nonnull BigInteger number1, @Nonnull BigInteger number2) {
    return !equal(number1, number2);
  }

  /**
   * Is the first number less than the second number?
   *
   * @param number1 the first number to compare, not null
   * @param number2 the second number to compare, not null
   * @return true if the first number is less than the second number, not null
   */
  @Nonnull
  static Boolean lessThan(@Nonnull BigInteger number1, @Nonnull BigInteger number2) {
    requireNonNull(number1);
    requireNonNull(number2);

    return number1.compareTo(number2) < 0;
  }

  /**
   * Is the first number greater than the second number?
   *
   * @param number1 the first number to compare, not null
   * @param number2 the second number to compare, not null
   * @return true if the first number is greater than the second number, not null
   */
  @Nonnull
  static Boolean greaterThan(@Nonnull BigInteger number1, @Nonnull BigInteger number2) {
    requireNonNull(number1);
    requireNonNull(number2);

    return number1.compareTo(number2) > 0;
  }

  /**
   * Does the given number fall within the range defined by its minimum and maximum (inclusive)?
   *
   * @param number  the number to compare, not null
   * @param minimum the low end of the range (inclusive), not null
   * @param maximum the high end of the range (inclusive), not null
   * @return true if the number falls within the range, not null
   */
  @Nonnull
  static Boolean inRange(@Nonnull BigInteger number, @Nonnull BigInteger minimum, @Nonnull BigInteger maximum) {
    requireNonNull(number);
    requireNonNull(minimum);
    requireNonNull(maximum);

    return number.compareTo(minimum) >= 0 && number.compareTo(maximum) <= 0;
  }

  /**
   * Does the given number not fall within the range defined by its minimum and maximum (inclusive)?
   *
   * @param number  the number to compare, not null
   * @param minimum the low end of the range (inclusive), not null
   * @param maximum the high end of the range (inclusive), not null
   * @return true if the number falls within the range, not null
   */
  @Nonnull
  static Boolean notInRange(@Nonnull BigInteger number, @Nonnull BigInteger minimum, @Nonnull BigInteger maximum) {
    return !inRange(number, minimum, maximum);
  }

  /**
   * Is the given number in the set of values?
   *
   * @param number the number to compare, not null
   * @param values the set of values against which to check, may be null
   * @return true if the number is in the set of values
   */
  @Nonnull
  static Boolean inSet(@Nonnull BigInteger number, @Nullable BigInteger... values) {
    requireNonNull(number);

    if (values == null || values.length == 0)
      return false;

    for (BigInteger value : values)
      if (equal(number, value))
        return true;

    return false;
  }

  /**
   * Is the given number not in the set of values?
   *
   * @param number the number to compare, not null
   * @param values the set of values against which to check, may be null
   * @return true if the number is not in the set of values
   */
  @Nonnull
  static Boolean notInSet(@Nonnull BigInteger number, @Nullable BigInteger... values) {
    return !inSet(number, values);
  }

  /**
   * Determines the "normalized" scale for the given number.
   * <p>
   * The standard behavior of {@link BigDecimal#scale()} is to return a negative number for each tens place,
   * e.g. {@code 10} has scale of {@code -1} and {@code 100} has scale {@code -2}.
   * <p>
   * This method instead returns {@code 0} for any negative scale, which is usually the behavior we want for our calculations.
   *
   * @param number the number for which normalized scale is determined, not null
   * @return the normalized scale, not null
   */
  @Nonnull
  private static Integer normalizedScale(@Nonnull BigDecimal number) {
    requireNonNull(number);

    int scale = number.scale();
    return scale < 0 ? 0 : scale;
  }
}

<|start_filename|>src/main/java/com/lokalized/LocalizedStringLoader.java<|end_filename|>
/*
 * Copyright 2017 Product Mog LLC.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.lokalized;

import com.lokalized.LocalizedString.LanguageFormTranslation;
import com.lokalized.LocalizedString.LanguageFormTranslationRange;
import com.lokalized.MinimalJson.Json;
import com.lokalized.MinimalJson.JsonArray;
import com.lokalized.MinimalJson.JsonObject;
import com.lokalized.MinimalJson.JsonObject.Member;
import com.lokalized.MinimalJson.JsonValue;

import javax.annotation.Nonnull;
import javax.annotation.concurrent.ThreadSafe;
import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.logging.Logger;
import java.util.stream.Collectors;

import static java.lang.String.format;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Objects.requireNonNull;

/**
 * Utility methods for loading localized strings files.
 *
 * @author <a href="https://revetkn.com"><NAME></a>
 */
@ThreadSafe
public final class LocalizedStringLoader {
  @Nonnull
  private static final Set<String> SUPPORTED_LANGUAGE_TAGS;
  @Nonnull
  private static final Map<String, LanguageForm> SUPPORTED_LANGUAGE_FORMS_BY_NAME;
  @Nonnull
  private static final Logger LOGGER;

  static {
    LOGGER = Logger.getLogger(LoggerType.LOCALIZED_STRING_LOADER.getLoggerName());

    SUPPORTED_LANGUAGE_TAGS = Collections.unmodifiableSet(Arrays.stream(Locale.getAvailableLocales())
        .map(locale -> locale.toLanguageTag())
        .collect(Collectors.toSet()));

    Set<LanguageForm> supportedLanguageForms = new LinkedHashSet<>();
    supportedLanguageForms.addAll(Arrays.asList(Gender.values()));
    supportedLanguageForms.addAll(Arrays.asList(Cardinality.values()));
    supportedLanguageForms.addAll(Arrays.asList(Ordinality.values()));

    Map<String, LanguageForm> supportedLanguageFormsByName = new LinkedHashMap<>();

    for (LanguageForm languageForm : supportedLanguageForms) {
      if (!languageForm.getClass().isEnum())
        throw new IllegalArgumentException(format("The %s interface must be implemented by enum types. %s is not an enum",
            LanguageForm.class.getSimpleName(), languageForm.getClass().getSimpleName()));

      String languageFormName = ((Enum<?>) languageForm).name();
      LanguageForm existingLanguageForm = supportedLanguageFormsByName.get(languageFormName);

      if (existingLanguageForm != null)
        throw new IllegalArgumentException(format("There is already a language form %s.%s whose name collides with %s.%s. " +
                "Language form names must be unique", existingLanguageForm.getClass().getSimpleName(), languageFormName,
            languageForm.getClass().getSimpleName(), languageFormName));

      // Massage Cardinality to match file format, e.g. "ONE" -> "CARDINALITY_ONE"
      if (languageForm instanceof Cardinality)
        languageFormName = LocalizedStringUtils.localizedStringNameForCardinalityName(languageFormName);

      // Massage Ordinality to match file format, e.g. "ONE" -> "ORDINALITY_ONE"
      if (languageForm instanceof Ordinality)
        languageFormName = LocalizedStringUtils.localizedStringNameForOrdinalityName(languageFormName);

      supportedLanguageFormsByName.put(languageFormName, languageForm);
    }

    SUPPORTED_LANGUAGE_FORMS_BY_NAME = Collections.unmodifiableMap(supportedLanguageFormsByName);
  }

  private LocalizedStringLoader() {
    // Non-instantiable
  }

  /**
   * Loads all localized string files present in the specified package on the classpath.
   * <p>
   * Filenames must correspond to the IETF BCP 47 language tag format.
   * <p>
   * Example filenames:
   * <ul>
   * <li>{@code en}</li>
   * <li>{@code es-MX}</li>
   * <li>{@code nan-Hant-TW}</li>
   * </ul>
   * <p>
   * Like any classpath reference, packages are separated using the {@code /} character.
   * <p>
   * Example package names:
   * <ul>
   * <li>{@code strings}
   * <li>{@code com/lokalized/strings}
   * </ul>
   * <p>
   * Note: this implementation only scans the specified package, it does not descend into child packages.
   *
   * @param classpathPackage location of a package on the classpath, not null
   * @return per-locale sets of localized strings, not null
   * @throws LocalizedStringLoadingException if an error occurs while loading localized string files
   */
  @Nonnull
  public static Map<Locale, Set<LocalizedString>> loadFromClasspath(@Nonnull String classpathPackage) {
    requireNonNull(classpathPackage);

    ClassLoader classLoader = LocalizedStringLoader.class.getClassLoader();
    URL url = classLoader.getResource(classpathPackage);

    if (url == null)
      throw new LocalizedStringLoadingException(format("Unable to find package '%s' on the classpath", classpathPackage));

    return loadFromDirectory(new File(url.getFile()));
  }

  /**
   * Loads all localized string files present in the specified directory.
   * <p>
   * Filenames must correspond to the IETF BCP 47 language tag format.
   * <p>
   * Example filenames:
   * <ul>
   * <li>{@code en}</li>
   * <li>{@code es-MX}</li>
   * <li>{@code nan-Hant-TW}</li>
   * </ul>
   * <p>
   * Note: this implementation only scans the specified directory, it does not descend into child directories.
   *
   * @param directory directory in which to search for localized string files, not null
   * @return per-locale sets of localized strings, not null
   * @throws LocalizedStringLoadingException if an error occurs while loading localized string files
   */
  @Nonnull
  public static Map<Locale, Set<LocalizedString>> loadFromFilesystem(@Nonnull Path directory) {
    requireNonNull(directory);
    return loadFromDirectory(directory.toFile());
  }

  // TODO: should we expose methods for loading a single file?

  /**
   * Loads all localized string files present in the specified directory.
   *
   * @param directory directory in which to search for localized string files, not null
   * @return per-locale sets of localized strings, not null
   * @throws LocalizedStringLoadingException if an error occurs while loading localized string files
   */
  @Nonnull
  private static Map<Locale, Set<LocalizedString>> loadFromDirectory(@Nonnull File directory) {
    requireNonNull(directory);

    if (!directory.exists())
      throw new LocalizedStringLoadingException(format("Location '%s' does not exist",
          directory));

    if (!directory.isDirectory())
      throw new LocalizedStringLoadingException(format("Location '%s' exists but is not a directory",
          directory));

    Map<Locale, Set<LocalizedString>> localizedStringsByLocale =
        new TreeMap<>((locale1, locale2) -> locale1.toLanguageTag().compareTo(locale2.toLanguageTag()));

    File[] files = directory.listFiles();

    if (files != null) {
      for (File file : files) {
        String languageTag = file.getName();

        if (SUPPORTED_LANGUAGE_TAGS.contains(languageTag)) {
          LOGGER.fine(format("Loading localized strings file '%s'...", languageTag));
          Locale locale = Locale.forLanguageTag(file.getName());
          localizedStringsByLocale.put(locale, parseLocalizedStringsFile(file));
        } else {
          LOGGER.fine(format("File '%s' does not correspond to a known language tag, skipping...", languageTag));
        }
      }
    }

    return Collections.unmodifiableMap(localizedStringsByLocale);
  }

  /**
   * Parses out a set of localized strings from the given file.
   *
   * @param file the file to parse, not null
   * @return the set of localized strings contained in the file, not null
   * @throws LocalizedStringLoadingException if an error occurs while parsing the localized string file
   */
  @Nonnull
  private static Set<LocalizedString> parseLocalizedStringsFile(@Nonnull File file) {
    requireNonNull(file);

    String canonicalPath;

    try {
      canonicalPath = file.getCanonicalPath();
    } catch (IOException e) {
      throw new LocalizedStringLoadingException(
          format("Unable to determine canonical path for localized strings file %s", file), e);
    }

    if (!Files.isRegularFile(file.toPath()))
      throw new LocalizedStringLoadingException(format("%s is not a regular file", canonicalPath));

    String localizedStringsFileContents;

    try {
      localizedStringsFileContents = new String(Files.readAllBytes(file.toPath()), UTF_8).trim();
    } catch (IOException e) {
      throw new LocalizedStringLoadingException(format("Unable to load localized strings file contents for %s",
          canonicalPath), e);
    }

    if ("".equals(localizedStringsFileContents))
      return Collections.emptySet();

    Set<LocalizedString> localizedStrings = new HashSet<>();
    JsonValue outerJsonValue = Json.parse(localizedStringsFileContents);

    if (!outerJsonValue.isObject())
      throw new LocalizedStringLoadingException(format("%s: a localized strings file must be comprised of a single JSON object", canonicalPath));

    JsonObject outerJsonObject = outerJsonValue.asObject();

    for (Member member : outerJsonObject) {
      String key = member.getName();
      JsonValue value = member.getValue();
      localizedStrings.add(parseLocalizedString(canonicalPath, key, value));
    }

    return Collections.unmodifiableSet(localizedStrings);
  }

  /**
   * Parses "toplevel" localized string data.
   * <p>
   * Operates recursively if alternatives are encountered.
   *
   * @param canonicalPath the unique path to the file (or URL) being parsed, used for error reporting. not null
   * @param key           the toplevel translation key, not null
   * @param jsonValue     the toplevel translation value - might be a simple string, might be a complex object. not null
   * @return a localized string instance, not null
   * @throws LocalizedStringLoadingException if an error occurs while parsing the localized string file
   */
  @Nonnull
  private static LocalizedString parseLocalizedString(@Nonnull String canonicalPath, @Nonnull String key, @Nonnull JsonValue jsonValue) {
    requireNonNull(canonicalPath);
    requireNonNull(key);
    requireNonNull(jsonValue);

    if (jsonValue.isString()) {
      // Simple case - just a key and a value, no translation rules
      //
      // Example format:
      //
      // {
      //   "Hello, world!" : "Приветствую, мир"
      // }

      String translation = jsonValue.asString();

      if (translation == null)
        throw new LocalizedStringLoadingException(format("%s: a translation is required for key '%s'", canonicalPath, key));

      return new LocalizedString.Builder(key).translation(translation).build();
    } else if (jsonValue.isObject()) {
      // More complex case, there can be placeholders and alternatives.
      //
      // Example format:
      //
      // {
      //   "I read {{bookCount}} books" : {
      //     "translation" : "I read {{bookCount}} {{books}}",
      //     "commentary" : "Message shown when user achieves her book-reading goal for the month",
      //     "placeholders" : {
      //       "books" : {
      //         "value" : "bookCount",
      //         "translations" : {
      //           "ONE" : "book",
      //           "OTHER" : "books"
      //         }
      //       }
      //     },
      //     "alternatives" : [
      //       {
      //         "bookCount == 0" : {
      //           "translation" : "I haven't read any books"
      //         }
      //       }
      //     ]
      //   }
      // }

      JsonObject localizedStringObject = jsonValue.asObject();

      String translation = null;

      JsonValue translationJsonValue = localizedStringObject.get("translation");

      if (translationJsonValue != null && !translationJsonValue.isNull()) {
        if (!translationJsonValue.isString())
          throw new LocalizedStringLoadingException(format("%s: translation must be a string for key '%s'", canonicalPath, key));

        translation = translationJsonValue.asString();
      }

      String commentary = null;

      JsonValue commentaryJsonValue = localizedStringObject.get("commentary");

      if (commentaryJsonValue != null && !commentaryJsonValue.isNull()) {
        if (!commentaryJsonValue.isString())
          throw new LocalizedStringLoadingException(format("%s: commentary must be a string for key '%s'", canonicalPath, key));

        commentary = commentaryJsonValue.asString();
      }

      Map<String, LanguageFormTranslation> languageFormTranslationsByPlaceholder = new LinkedHashMap<>();

      JsonValue placeholdersJsonValue = localizedStringObject.get("placeholders");

      if (placeholdersJsonValue != null && !placeholdersJsonValue.isNull()) {
        if (!placeholdersJsonValue.isObject())
          throw new LocalizedStringLoadingException(format("%s: the placeholders value must be an object. Key is '%s'", canonicalPath, key));

        JsonObject placeholdersJsonObject = placeholdersJsonValue.asObject();

        for (Member placeholderMember : placeholdersJsonObject) {
          String placeholderKey = placeholderMember.getName();
          JsonValue placeholderJsonValue = placeholderMember.getValue();
          String value = null;
          LanguageFormTranslationRange rangeValue = null;

          if (!placeholderJsonValue.isObject())
            throw new LocalizedStringLoadingException(format("%s: the placeholder value must be an object. Key is '%s'", canonicalPath, key));

          JsonObject placeholderJsonObject = placeholderJsonValue.asObject();

          JsonValue valueJsonValue = placeholderJsonObject.get("value");
          JsonValue rangeJsonValue = placeholderJsonObject.get("range");
          boolean hasValue = valueJsonValue != null && !valueJsonValue.isNull();
          boolean hasRangeValue = rangeJsonValue != null && !rangeJsonValue.isNull();

          if (!hasValue && !hasRangeValue)
            throw new LocalizedStringLoadingException(format("%s: a placeholder translation value or range is required. Key is '%s'", canonicalPath, key));

          if (hasValue && hasRangeValue)
            throw new LocalizedStringLoadingException(format("%s: a placeholder translation cannot have both a value and a range. Key is '%s'", canonicalPath, key));

          if (hasRangeValue) {
            if (!rangeJsonValue.isObject())
              throw new LocalizedStringLoadingException(format("%s: the placeholder translation range must be an object. Key is '%s'", canonicalPath, key));

            JsonObject rangeJsonObject = rangeJsonValue.asObject();
            JsonValue rangeValueStartJsonValue = rangeJsonObject.get("start");
            JsonValue rangeValueEndJsonValue = rangeJsonObject.get("end");

            if (rangeValueStartJsonValue == null || rangeValueStartJsonValue.isNull())
              throw new LocalizedStringLoadingException(format("%s: a placeholder translation range start is required. Key is '%s'", canonicalPath, key));

            if (rangeValueEndJsonValue == null || rangeValueEndJsonValue.isNull())
              throw new LocalizedStringLoadingException(format("%s: a placeholder translation range end is required. Key is '%s'", canonicalPath, key));

            if (!rangeValueStartJsonValue.isString())
              throw new LocalizedStringLoadingException(format("%s: a placeholder translation range start must be a string. Key is '%s'", canonicalPath, key));

            if (!rangeValueEndJsonValue.isString())
              throw new LocalizedStringLoadingException(format("%s: a placeholder translation range end must be a string. Key is '%s'", canonicalPath, key));

            rangeValue = new LanguageFormTranslationRange(rangeValueStartJsonValue.asString(), rangeValueEndJsonValue.asString());
          } else {
            if (!valueJsonValue.isString())
              throw new LocalizedStringLoadingException(format("%s: a placeholder translation value must be a string. Key is '%s'", canonicalPath, key));

            value = valueJsonValue.asString();
          }

          JsonValue translationsJsonValue = placeholderJsonObject.get("translations");

          if (translationsJsonValue == null || translationsJsonValue.isNull())
            continue;

          if (!translationsJsonValue.isObject())
            throw new LocalizedStringLoadingException(format("%s: the placeholder translations value must be an object. Key is '%s'", canonicalPath, key));

          Map<LanguageForm, String> translationsByLanguageForm = new LinkedHashMap<>();

          JsonObject translationsJsonObject = translationsJsonValue.asObject();

          for (Member translationMember : translationsJsonObject) {
            String languageFormTranslationKey = translationMember.getName();
            JsonValue languageFormTranslationJsonValue = translationMember.getValue();
            LanguageForm languageForm = SUPPORTED_LANGUAGE_FORMS_BY_NAME.get(languageFormTranslationKey);

            if (languageForm == null)
              throw new LocalizedStringLoadingException(format("%s: unexpected placeholder translation language form encountered. Key is '%s'. " +
                      "You provided '%s', valid values are [%s]", canonicalPath, key, languageFormTranslationKey,
                  SUPPORTED_LANGUAGE_FORMS_BY_NAME.keySet().stream().collect(Collectors.joining(", "))));

            if (!languageFormTranslationJsonValue.isString())
              throw new LocalizedStringLoadingException(format("%s: the placeholder translation value must be a string. Key is '%s'", canonicalPath, key));

            translationsByLanguageForm.put(languageForm, languageFormTranslationJsonValue.asString());
          }

          LanguageFormTranslation languageFormTranslation = rangeValue != null
              ? new LanguageFormTranslation(rangeValue, translationsByLanguageForm)
              : new LanguageFormTranslation(value, translationsByLanguageForm);

          languageFormTranslationsByPlaceholder.put(placeholderKey, languageFormTranslation);
        }
      }

      List<LocalizedString> alternatives = new ArrayList<>();

      JsonValue alternativesJsonValue = localizedStringObject.get("alternatives");

      if (alternativesJsonValue != null && !alternativesJsonValue.isNull()) {
        if (!alternativesJsonValue.isArray())
          throw new LocalizedStringLoadingException(format("%s: alternatives must be an array. Key is '%s'", canonicalPath, key));

        JsonArray alternativesJsonArray = alternativesJsonValue.asArray();

        for (JsonValue alternativeJsonValue : alternativesJsonArray) {
          if (alternativeJsonValue == null || alternativeJsonValue.isNull())
            continue;

          JsonObject outerJsonObject = alternativeJsonValue.asObject();

          if (!outerJsonObject.isObject())
            throw new LocalizedStringLoadingException(format("%s: alternative value must be an object. Key is '%s'", canonicalPath, key));

          for (Member member : outerJsonObject) {
            String alternativeKey = member.getName();
            JsonValue alternativeValue = member.getValue();
            alternatives.add(parseLocalizedString(canonicalPath, alternativeKey, alternativeValue));
          }
        }
      }

      return new LocalizedString.Builder(key)
          .translation(translation)
          .commentary(commentary)
          .languageFormTranslationsByPlaceholder(languageFormTranslationsByPlaceholder)
          .alternatives(alternatives)
          .build();
    } else {
      throw new LocalizedStringLoadingException(format("%s: either a translation string or object value is required for key '%s'",
          canonicalPath, key));
    }
  }
}

<|start_filename|>src/main/java/com/lokalized/Strings.java<|end_filename|>
/*
 * Copyright 2017 Product Mog LLC.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.lokalized;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.Locale;
import java.util.Map;

/**
 * Contract for localized string providers - given a key and placeholders, return a localized string.
 * <p>
 * Format is {@code "You are missing {{requiredFieldCount}} required fields."}
 *
 * @author <a href="https://revetkn.com"><NAME></a>
 */
public interface Strings {
  /**
   * Gets a localized string for the given key.
   * <p>
   * If no localized string is available, the key is returned.
   *
   * @param key the localization key, not null
   * @return a localized string for the key, not null
   */
  @Nonnull
  String get(@Nonnull String key);

  /**
   * Gets a localized string for the given key.
   * <p>
   * If no localized string is available, the key is returned.
   *
   * @param key    the localization key, not null
   * @param locale the preferred locale for the string, may be null
   * @return a localized string for the key, not null
   */
  @Nonnull
  String get(@Nonnull String key, @Nullable Locale locale);

  /**
   * Gets a localized string for the given key.
   * <p>
   * If no localized string is available, the key is returned.
   *
   * @param key          the localization key, not null
   * @param placeholders the placeholders to insert into the string, may be null
   * @return a localized string for the key, not null
   */
  @Nonnull
  String get(@Nonnull String key, @Nullable Map<String, Object> placeholders);

  /**
   * Gets a localized string for the given key.
   * <p>
   * If no localized string is available, the key is returned.
   *
   * @param key          the localization key, not null
   * @param placeholders the placeholders to insert into the string, may be null
   * @param locale       the preferred locale for the string, may be null
   * @return a localized string for the key, not null
   */
  @Nonnull
  String get(@Nonnull String key, @Nullable Map<String, Object> placeholders, @Nullable Locale locale);
}