EnumParser.java

package org.klojang.convert;

import org.klojang.check.Check;

import java.util.*;
import java.util.function.Supplier;
import java.util.function.UnaryOperator;

import static org.klojang.check.CommonChecks.*;
import static org.klojang.convert.EnumParser.ParseTarget.*;
import static org.klojang.convert.NumberMethods.isIntegral;

/**
 * Parses strings into enum constants. Internally {@code EnumParser} maintains a
 * string-to-enum map containing normalized versions of {@link Enum#name()} and
 * {@link Enum#toString()} as keys. The strings to be parsed are normalized using the same
 * normalization function, and then looked up in the map. The normalizer function is
 * customizable. Note that the {@link #parse(Object) parse} method takes an argument of
 * type {@code Object} (rather than {@code String}). You can, in fact, instruct the parser
 * to be prepared for receiving the ordinal value of an enum constant. You can even
 * instruct it to be prepared for simply receiving an enum constant itself. This is may be
 * useful in dynamic contexts where it is not known beforehand whether the incoming value
 * perhaps already is (or has been converted to) an enum constant. By default, the parser
 * will be on the lookout for the name, the ordinal value and the string representation of
 * the enum constants.
 *
 * <blockquote><pre>{@code
 * enum TransportType {
 *  CAR, BIKE, TRAIN;
 *
 *  private static EnumParser<TransportType> parser = new EnumParser(TransportType.class);
 *
 *  @JsonCreator
 *  public static TransportType parse(String input) {
 *      return parser.parse(input);
 *  }
 * }
 * }</pre></blockquote>
 *
 * @param <T> The type of the {@code enum}
 * @author Ayco Holleman
 */
public final class EnumParser<T extends Enum<T>> {

  /**
   * Symbolic constants for what the value to be converted represents.
   */
  public enum ParseTarget {
    /**
     * Indicates that the value to be converted is supposed to be the
     * {@linkplain Enum#name() name} of an enum constant.
     */
    NAME,
    /**
     * Indicates that the value to be converted is supposed to be the
     * {@linkplain Enum#ordinal() ordinal value} of an enum constant.
     */
    ORDINAL,
    /**
     * Indicates that the value to be converted is supposed to be the string
     * representation of an enum constant.
     */
    TO_STRING,
    /**
     * Indicates that the value to be converted is supposed to be already an enum constant
     * and must be returned <i>as-is</i> by the parser. This may be useful in dynamic
     * contexts where it is not known beforehand whether the incoming value perhaps
     * already is (or has been converted to) an enum constant.
     */
    IDENTITY
  }

  private static final String BAD_KEY = "duplicate key: ${arg}";

  /**
   * The default normalization function. Removes spaces, hyphens and underscores and
   * returns an all-lowercase string.
   */
  public static final UnaryOperator<String> DEFAULT_NORMALIZER =
        s -> Check.notNull(s).ok().replaceAll("[-_ ]", "").toLowerCase();

  private final Class<T> enumClass;
  private final UnaryOperator<String> normalizer;
  private final Set<ParseTarget> targets;
  private final Map<String, T> lookups;

  /**
   * Creates an {@code EnumParser} for the specified enum class, using the
   * {@link #DEFAULT_NORMALIZER}.
   *
   * @param enumClass The enum class
   */
  public EnumParser(Class<T> enumClass) {
    this(enumClass, DEFAULT_NORMALIZER);
  }

  /**
   * Creates an {@code EnumParser} for the specified enum class, using the specified
   * {@code normalizer} to normalize the strings to be parsed.
   *
   * @param enumClass the enum class managed by this {@code EnumParser}
   * @param normalizer the normalization function
   */
  public EnumParser(Class<T> enumClass, UnaryOperator<String> normalizer) {
    this(enumClass, normalizer, EnumSet.of(NAME, ORDINAL, TO_STRING));
  }

  /**
   * Creates an {@code EnumParser} for the specified enum class, using the specified
   * {@code normalizer} to normalize the strings to be parsed.
   *
   * @param enumClass the enum class managed by this {@code EnumParser}
   * @param normalizer the normalization function
   * @param parseTargets the aspects of an enum constant that the values to be
   *       converted may represent (the constant's name, ordinal value, string
   *       representation, or the constant itself).
   */
  public EnumParser(Class<T> enumClass, UnaryOperator<String> normalizer,
        Set<ParseTarget> parseTargets) {
    this.enumClass = Check.notNull(enumClass, "enumClass").ok();
    this.normalizer = Check.notNull(normalizer, "normalizer").ok();
    this.targets = Check.that(parseTargets, "parseTargets").is(deepNotEmpty()).ok();
    HashMap<String, T> tmp = new HashMap<>();
    if (parseTargets.contains(NAME)) {
      for (T e : enumClass.getEnumConstants()) {
        tmp.put(normalize(e.name()), e);
      }
    }
    if (parseTargets.contains(TO_STRING)) {
      for (T e : enumClass.getEnumConstants()) {
        tmp.put(normalize(e.toString()), e);
      }
    }
    this.lookups = Map.copyOf(tmp);
  }

  /**
   * Parses the specified value into an enum constant.
   *
   * @param value The value to be mapped an enum constant.
   * @return The enum constant
   * @throws TypeConversionException If the value was {@code null} or could not be
   *       mapped to one of the enum's constants.
   */
  public T parse(Object value) throws TypeConversionException {
    if (value != null) {
      if (targets.contains(ORDINAL) && isIntegral(value.getClass())) {
        int ordinal = NumberMethods.convert((Number) value, Integer.class);
        return Check.that(ordinal)
              .is(indexOf(), enumClass.getEnumConstants(), noSuchConstant(value))
              .mapToObj(x -> enumClass.getEnumConstants()[x]);
      } else if (targets.contains(IDENTITY) && enumClass.isInstance(value)) {
        return enumClass.cast(value);
      }
    }
    String key = normalize(Objects.toString(value));
    return Check.that(lookups.get(key)).is(notNull(), noSuchConstant(value)).ok();
  }

  private String normalize(String s) {
    try {
      return normalizer.apply(s);
    } catch (TypeConversionException tce) {
      throw tce;
    } catch (Throwable t) {
      throw new TypeConversionException(s, enumClass, t.getMessage());
    }
  }

  private Supplier<TypeConversionException> noSuchConstant(Object value) {
    return () -> new TypeConversionException(value, enumClass);
  }


}