code: package morfologik.stemming;
  
  import static morfologik.stemming.DictionaryAttribute.*;
  
  import java.io.IOException;
  import java.io.InputStream;
  import java.io.InputStreamReader;
  import java.io.Writer;
  import java.nio.ByteBuffer;
  import java.nio.CharBuffer;
  import java.nio.charset.CharacterCodingException;
  import java.nio.charset.Charset;
  import java.nio.charset.CharsetDecoder;
  import java.nio.charset.CharsetEncoder;
  import java.nio.charset.CodingErrorAction;
  import java.nio.charset.UnsupportedCharsetException;
  import java.nio.file.Path;
  import java.util.Collections;
  import java.util.EnumMap;
  import java.util.EnumSet;
  import java.util.Enumeration;
  import java.util.HashMap;
  import java.util.LinkedHashMap;
  import java.util.List;
  import java.util.Locale;
  import java.util.Map;
  import java.util.Properties;
  
  /**
   * Description of attributes, their types and default values.
   */
  public final class DictionaryMetadata {
    /**
     * Default attribute values.
     */
    private static Map<DictionaryAttribute, String> DEFAULT_ATTRIBUTES = new DictionaryMetadataBuilder()
      .frequencyIncluded(false)
      .ignorePunctuation()
      .ignoreNumbers()
      .ignoreCamelCase()
      .ignoreAllUppercase()
      .ignoreDiacritics()
      .convertCase()
      .supportRunOnWords()
      .toMap();
  
    /**
     * Required attributes.
     */
    private static EnumSet<DictionaryAttribute> REQUIRED_ATTRIBUTES = EnumSet.of(
        SEPARATOR,
        ENCODER,
        ENCODING);
  
    /**
     * A separator character between fields (stem, lemma, form). The character
     * must be within byte range (FSA uses bytes internally).
     */
    private byte separator;
    private char separatorChar;
  
    /**
     * Encoding used for converting bytes to characters and vice versa.
     */
    private String encoding;
  
    private Charset charset;
    private Locale locale = Locale.getDefault();
  
    /**
     * Replacement pairs for non-obvious candidate search in a speller dictionary.
     */
    private LinkedHashMap<String, List<String>> replacementPairs = new LinkedHashMap<>();
  
    /**
     * Conversion pairs for input conversion, for example to replace ligatures.
     */
    private LinkedHashMap<String, String> inputConversion = new LinkedHashMap<>();
  
    /**
     * Conversion pairs for output conversion, for example to replace ligatures.
     */
    private LinkedHashMap<String, String> outputConversion = new LinkedHashMap<>();
  
    /**
     * Equivalent characters (treated similarly as equivalent chars with and without
     * diacritics). For example, Polish <tt>ł</tt> can be specified as equivalent to <tt>l</tt>.
     * 
     * This implements a feature similar to hunspell MAP in the affix file.
     */
    private LinkedHashMap<Character, List<Character>> equivalentChars = new LinkedHashMap<>();
  
    /**
     * All attributes.
     */
    private final EnumMap<DictionaryAttribute, String> attributes;
  
    /**
     * All "enabled" boolean attributes.
     */
    private final EnumMap<DictionaryAttribute,Boolean> boolAttributes;
  
    /**
     * Sequence encoder.
     */
    private EncoderType encoderType;
  
    /**
     * Expected metadata file extension.
     */
    public final static String METADATA_FILE_EXTENSION = "info";
  
    /**
     * @return Return all metadata attributes.  
     */
    public Map<DictionaryAttribute, String> getAttributes() {
      return Collections.unmodifiableMap(attributes);
    }
  
    // Cached attrs.
    public String getEncoding()      { return encoding; }
    public byte getSeparator()       { return separator; }
    public Locale getLocale()        { return locale; }
  
    public LinkedHashMap<String, String> getInputConversionPairs() { return inputConversion; }
    public LinkedHashMap<String, String> getOutputConversionPairs() { return outputConversion; }
  
    public LinkedHashMap<String, List<String>> getReplacementPairs() { return replacementPairs; }
    public LinkedHashMap<Character, List<Character>> getEquivalentChars() { return equivalentChars; }
  
    // Dynamically fetched.
    public boolean isFrequencyIncluded()    { return boolAttributes.get(FREQUENCY_INCLUDED); }
    public boolean isIgnoringPunctuation()  { return boolAttributes.get(IGNORE_PUNCTUATION); }
    public boolean isIgnoringNumbers()      { return boolAttributes.get(IGNORE_NUMBERS); }
    public boolean isIgnoringCamelCase()    { return boolAttributes.get(IGNORE_CAMEL_CASE); }
    public boolean isIgnoringAllUppercase() { return boolAttributes.get(IGNORE_ALL_UPPERCASE); }
    public boolean isIgnoringDiacritics()   { return boolAttributes.get(IGNORE_DIACRITICS); }
    public boolean isConvertingCase()       { return boolAttributes.get(CONVERT_CASE); }
    public boolean isSupportingRunOnWords() { return boolAttributes.get(RUN_ON_WORDS); }
  
    /**
     * Create an instance from an attribute map.
     * 
     * @param attrs A set of {@link DictionaryAttribute} keys and their associated values.
     * @see DictionaryMetadataBuilder
     */
    public DictionaryMetadata(Map<DictionaryAttribute, String> attrs) {
      this.boolAttributes = new EnumMap<DictionaryAttribute,Boolean>(DictionaryAttribute.class);
      this.attributes = new EnumMap<DictionaryAttribute, String>(DictionaryAttribute.class);
      this.attributes.putAll(attrs);
  
      EnumMap<DictionaryAttribute, String> attributeMap = new EnumMap<DictionaryAttribute, String>(DEFAULT_ATTRIBUTES);
      attributeMap.putAll(attrs);
  
      // Convert some attrs from the map to local fields for performance reasons.
      EnumSet<DictionaryAttribute> requiredAttributes = EnumSet.copyOf(REQUIRED_ATTRIBUTES);
  
      for (Map.Entry<DictionaryAttribute,String> e : attributeMap.entrySet()) {
        requiredAttributes.remove(e.getKey());
  
        // Run validation and conversion on all of them.
        Object value = e.getKey().fromString(e.getValue());
        switch (e.getKey()) {
          case ENCODING:
            this.encoding = e.getValue();
            if (!Charset.isSupported(encoding)) {
              throw new IllegalArgumentException("Encoding not supported on this JVM: "
                  + encoding);
            }
            this.charset = (Charset) value;
            break;
    
          case SEPARATOR:
            this.separatorChar = (Character) value;
            break;
    
          case LOCALE:
            this.locale = (Locale) value;
            break;
    
          case ENCODER:
            this.encoderType = (EncoderType) value;
            break;
    
          case INPUT_CONVERSION:
          {
            @SuppressWarnings("unchecked")
            LinkedHashMap<String, String> gvalue = (LinkedHashMap<String, String>) value;
            this.inputConversion = gvalue;
          }
          break;
    
          case OUTPUT_CONVERSION:
          {
            @SuppressWarnings("unchecked")
            LinkedHashMap<String, String> gvalue = (LinkedHashMap<String, String>) value;
            this.outputConversion = gvalue;
          }
          break;
    
          case REPLACEMENT_PAIRS:
          {
            @SuppressWarnings("unchecked")
            LinkedHashMap<String, List<String>> gvalue = (LinkedHashMap<String, List<String>>) value;
            this.replacementPairs = gvalue;
          }
          break;
    
          case EQUIVALENT_CHARS:
          {
            @SuppressWarnings("unchecked")
            LinkedHashMap<Character, List<Character>> gvalue = (LinkedHashMap<Character, List<Character>>) value;
            this.equivalentChars = gvalue;
          }
          break;
    
          case IGNORE_PUNCTUATION:
          case IGNORE_NUMBERS:
          case IGNORE_CAMEL_CASE:
          case IGNORE_ALL_UPPERCASE:
          case IGNORE_DIACRITICS:
          case CONVERT_CASE:
          case RUN_ON_WORDS:
          case FREQUENCY_INCLUDED:
            this.boolAttributes.put(e.getKey(), (Boolean) value);
            break;
    
          case AUTHOR:
          case LICENSE:
          case CREATION_DATE:
            // Just run validation.
            e.getKey().fromString(e.getValue());
            break;
    
          default:
            throw new RuntimeException("Unexpected code path (attribute should be handled but is not): " + e.getKey());
        }
      }
  
      if (!requiredAttributes.isEmpty()) {
        throw new IllegalArgumentException("At least one the required attributes was not provided: "
            + requiredAttributes.toString());
      }
  
      // Sanity check.
      CharsetEncoder encoder = getEncoder();
      try {
        ByteBuffer encoded = encoder.encode(CharBuffer.wrap(new char [] { separatorChar }));
        if (encoded.remaining() > 1) {
          throw new IllegalArgumentException("Separator character is not a single byte in encoding "
              + encoding + ": " + separatorChar);
        }
        this.separator = encoded.get();
      } catch (CharacterCodingException e) {
        throw new IllegalArgumentException("Separator character cannot be converted to a byte in "
            + encoding + ": " + separatorChar, e);
      }
    }
  
    /**
     * @return Returns a new {@link CharsetDecoder} for the {@link #encoding}. 
     */
    public CharsetDecoder getDecoder() {
      try {
        return charset.newDecoder()
                .onMalformedInput(CodingErrorAction.REPORT)
                .onUnmappableCharacter(CodingErrorAction.REPORT);
      } catch (UnsupportedCharsetException e) {
        throw new RuntimeException(
            "FSA's encoding charset is not supported: " + encoding);
      }
    }
  
    /**
     * @return Returns a new {@link CharsetEncoder} for the {@link #encoding}.
     */
    public CharsetEncoder getEncoder() {
      try {
        return charset.newEncoder()
          .onMalformedInput(CodingErrorAction.REPORT)
          .onUnmappableCharacter(CodingErrorAction.REPORT);
      } catch (UnsupportedCharsetException e) {
        throw new RuntimeException(
            "FSA's encoding charset is not supported: " + encoding);
      }
    }
  
    /**
     * @return Return sequence encoder type.
     */
    public EncoderType getSequenceEncoderType() {
      return encoderType;
    }
  
    /**
     * @return Returns the {@link #separator} byte converted to a single
     *         <code>char</code>.
     * @throws RuntimeException
     *           if this conversion is for some reason impossible (the byte is a
     *           surrogate pair, FSA's {@link #encoding} is not available).
     */
    public char getSeparatorAsChar() {
      return separatorChar;
    }
  
    /**
     * @return A shortcut returning {@link DictionaryMetadataBuilder}.
     */
    public static DictionaryMetadataBuilder builder() {
      return new DictionaryMetadataBuilder();
    }
    
    /**
     * Returns the expected name of the metadata file, based on the name of the
     * dictionary file. The expected name is resolved by truncating any
     * file extension of <code>name</code> and appending
     * {@link DictionaryMetadata#METADATA_FILE_EXTENSION}.
     * 
     * @param dictionaryFile The name of the dictionary (<code>*.dict</code>) file. 
     * @return Returns the expected name of the metadata file. 
     */
    public static String getExpectedMetadataFileName(String dictionaryFile) {
      final int dotIndex = dictionaryFile.lastIndexOf('.');
      final String featuresName;
      if (dotIndex >= 0) {
        featuresName = dictionaryFile.substring(0, dotIndex) + "." + METADATA_FILE_EXTENSION;
      } else {
        featuresName = dictionaryFile + "." + METADATA_FILE_EXTENSION;
      }
    
      return featuresName;
    }
  
    /**
     * @param dictionary The location of the dictionary file.
     * @return Returns the expected location of a metadata file.
     */
    public static Path getExpectedMetadataLocation(Path dictionary) {
      return dictionary.resolveSibling(
          getExpectedMetadataFileName(dictionary.getFileName().toString()));
    }
  
    /**
     * Read dictionary metadata from a property file (stream).
     * 
     * @param metadataStream The stream with metadata. 
     * @return Returns {@link DictionaryMetadata} read from a the stream (property file).
     * @throws IOException Thrown if an I/O exception occurs. 
     */
    public static DictionaryMetadata read(InputStream metadataStream) throws IOException {
      Map<DictionaryAttribute, String> map = new HashMap<DictionaryAttribute, String>();
      final Properties properties = new Properties();
      properties.load(new InputStreamReader(metadataStream, "UTF-8"));
  
      // Handle back-compatibility for encoder specification.
      if (!properties.containsKey(DictionaryAttribute.ENCODER.propertyName)) {
        boolean hasDeprecated = properties.containsKey("fsa.dict.uses-suffixes") ||
                                properties.containsKey("fsa.dict.uses-infixes") ||
                                properties.containsKey("fsa.dict.uses-prefixes");
  
        boolean usesSuffixes = Boolean.valueOf(properties.getProperty("fsa.dict.uses-suffixes", "true"));
        boolean usesPrefixes = Boolean.valueOf(properties.getProperty("fsa.dict.uses-prefixes", "false"));
        boolean usesInfixes  = Boolean.valueOf(properties.getProperty("fsa.dict.uses-infixes",  "false"));
  
        final EncoderType encoder;
        if (usesInfixes) {
          encoder = EncoderType.INFIX;
        } else if (usesPrefixes) {
          encoder = EncoderType.PREFIX;
        } else if (usesSuffixes) {
          encoder = EncoderType.SUFFIX;
        } else {
          encoder = EncoderType.NONE;
        }
  
        if (!hasDeprecated) {
          throw new IOException("Use an explicit " +
              DictionaryAttribute.ENCODER.propertyName + "=" + encoder.name() +
              " metadata key: ");
        }
  
        throw new IOException("Deprecated encoder keys in metadata. Use " +
            DictionaryAttribute.ENCODER.propertyName + "=" + encoder.name());
      }
  
      for (Enumeration<?> e = properties.propertyNames(); e.hasMoreElements();) {
        String key = (String) e.nextElement();
        map.put(DictionaryAttribute.fromPropertyName(key), properties.getProperty(key));
      }
  
      return new DictionaryMetadata(map);
    }
  
    /**
     * Write dictionary attributes (metadata).
     * 
     * @param writer The writer to write to.
     * @throws IOException Thrown when an I/O error occurs.
     */
    public void write(Writer writer) throws IOException {
      final Properties properties = new Properties();
  
      for (Map.Entry<DictionaryAttribute, String> e : getAttributes().entrySet()) {
        properties.setProperty(e.getKey().propertyName, e.getValue());
      }
  
      properties.store(writer, "# " + getClass().getName());
    }
  }
  