All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.sshtools.jini.INI Maven / Gradle / Ivy

The newest version!
/**
 * Copyright © 2023 JAdaptive Limited ([email protected])
 *
 * 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.sshtools.jini;

import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.io.Serializable;
import java.io.StringReader;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.ParseException;
import java.util.AbstractCollection;
import java.util.AbstractSet;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.Spliterator;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.stream.Collectors;

import com.sshtools.jini.INIReader.MultiValueMode;
import com.sshtools.jini.Interpolation.Interpolator;

/**
 * Represents the top-level of an INI format document.
 * 

* It contains multiple string key/value pairs, internally stored as string * arrays. There are convenience methods to convert values to primitive types * when retrieved. *

* It may also contains multiple {@link Section}s, which provide the same * key/value pairs as the top level document, and themselves may contain further * nested sections. *

* An {@link INI} may either be directly created using {@link INI#create()} and * alternatives. For alternative configuration of the document, use * {@link Builder}, or to create a document from a text stream or string use an * {@link INIReader} created by an {@link INIReader.Builder}. * */ public interface INI extends Data { /** * Missing variable mode */ public enum MissingVariableMode { /** * An error will be thrown if a variable is missing */ ERROR, /** * Missing variables will be interpreted as empty strings */ BLANK, /** * Missing variables will be left as is */ SKIP } /** * Use to configure when special characters in written string values are escaped. */ public enum EscapeMode { /** * Special characters in strings will never be escaped with the configured escape character. */ NEVER, /** * Special characters in strings will only be escaped in quoted strings. */ QUOTED, /** * Special characters in strings will always be escaped with the configured escape character. */ ALWAYS } public static String[] merge(String[]... vals) { var l = new ArrayList(); for(var val : vals) { l.addAll(Arrays.asList(val)); } return l.toArray(new String[0]); } /** * Helper for lazy initialisation of an empty read onlyt document. */ final static class EmptyContainer { private final static INI empty = INI.create().readOnly(); } /** * Empty, read-only document */ static INI blank() { return EmptyContainer.empty; } /** * Build {@link INI} objects. Builders may be re-used, once {@link #build()} is * used, any changes to the builder will not affect the created instance. */ public final static class Builder { private boolean caseSensitiveKeys = false; private boolean caseSensitiveSections = false; private boolean preserveOrder = true; private boolean emptyValues = true; private Optional interpolator = Optional.empty(); private Optional variablePattern = Optional.empty(); private MissingVariableMode missingVariableMode = MissingVariableMode.ERROR; /** * Configure how to react when a variable is encountered that does not exist. * * @param missingVariableMode missing variable mode * @return this for chaining */ public final Builder withMissingVariableMode(MissingVariableMode missingVariableMode) { this.missingVariableMode = missingVariableMode; return this; } /** * Configure the regular expression pattern to use to extract interpolatable variables. * * @param variablePattern variable pattern * @return this for chaining */ public final Builder withVariablePattern(String variablePattern) { this.variablePattern = Optional.of(variablePattern); return this; } /** * Configure an "Interpolator", that processes string values before they * are returned, replacing string variable patterns. See {@link Interpolation} * for some default interpolators. * * @param interpolator interpolator * @return this for chaining */ public final Builder withInterpolator(Interpolator interpolator) { this.interpolator = Optional.of(interpolator); return this; } /** * Configure the to not allow empty values. Any null or empty array * values inserted will throw a {@link IllegalArgumentException}. * * @param emptyValues allow empty values * @return this for chaining */ public final Builder withoutEmptyValues() { return withEmptyValues(false); } /** * Configure the document whether to allow empty values. When true * any null values inserted will be converted to empty values. When * false any null or empty array values inserted will * throw a {@link IllegalArgumentException}. *

* By default empty values are allowed. * * @param emptyValues allow empty values * @return this for chaining */ public final Builder withEmptyValues(boolean emptyValues) { this.emptyValues = emptyValues; return this; } /** * Configure the document to use case sensitive keys. * * @return this for chaining */ public final Builder withCaseSensitiveKeys() { return withCaseSensitiveKeys(true); } /** * Configure whether the document uses case sensitive keys. * * @param caseSensitiveKeys case sensitive keys * @return this for chaining */ public final Builder withCaseSensitiveKeys(boolean caseSensitiveKeys) { this.caseSensitiveKeys = caseSensitiveKeys; return this; } /** * Configure the document to use case sensitive sections. * * @return this for chaining */ public final Builder withCaseSensitiveSections() { return withCaseSensitiveSections(true); } /** * Configure whether the document uses case sensitive sections. * * @param caseSensitiveSections case sensitive sections * @return this for chaining */ public final Builder withCaseSensitiveSections(boolean caseSensitiveSections) { this.caseSensitiveSections = caseSensitiveSections; return this; } /** * Configure the document to not preserve order of insertion of values and * sections. * * @return this for chaining */ public final Builder withoutPreserveOrder() { return withPreserveOrder(false); } /** * Configure whether document should preserve order of insertion of values and * sections. * * @return this for chaining */ public final Builder withPreserveOrder(boolean preserveOrder) { this.preserveOrder = preserveOrder; return this; } /** * Create the configured {@link INI} document. * * @return document */ public INI build() { return new DefaultINI(emptyValues, preserveOrder, caseSensitiveKeys, caseSensitiveSections, interpolator, variablePattern, missingVariableMode); } } /** * Create a new default {@link INI} document. It will have case insensitive keys * for values and sections, and insertion order will be preserved. * * @return document */ public static INI create() { return new INI.Builder().build(); } /** * Parse a file that contains a document in INI format. It will have case * insensitive keys for values and sections, and insertion order will be * preserved. * * @return document */ public static INI fromFile(Path file) { try (var in = Files.newBufferedReader(file)) { return fromReader(in); } catch (IOException ioe) { throw new UncheckedIOException(ioe); } } public final static class DefaultINI extends AbstractData implements INI { DefaultINI(boolean emptyValues, boolean preserveOrder, boolean caseInsensitiveKeys, boolean caseInsensitiveSections, Map values, Map sections, Optional interpolator, Optional variablePattern, MissingVariableMode missingVariableMode) { super(emptyValues, preserveOrder, caseInsensitiveKeys, caseInsensitiveSections, values, sections, interpolator, variablePattern, missingVariableMode); } DefaultINI(boolean emptyValues, boolean preserveOrder, boolean caseSensitiveKeys, boolean caseSenstiveSections, Optional interpolator, Optional variablePattern, MissingVariableMode missingVariableMode) { super(emptyValues, preserveOrder, caseSensitiveKeys, caseSenstiveSections, interpolator, variablePattern, missingVariableMode); } /** * Create a read only facade to an existing docuiment. * * @param document document * @return read only document */ @Override public INI readOnly() { var s = new HashMap(); sections.forEach((k, v) -> s.put(k, Arrays.asList(v).stream().map(vv -> vv.readOnly()) .collect(Collectors.toList()).toArray(new Section[0]))); return new DefaultINI(emptyValues, preserveOrder, caseSensitiveKeys, caseSensitiveSections, Collections.unmodifiableMap(values), Collections.unmodifiableMap(s), interpolator, variablePattern, missingVariableMode); } @Override public Optional

parentOr() { return Optional.empty(); } @Override public INI document() { return this; } /** * Merge one or more documents to make a new document that contains all the * sections and keys of both. * * @param document document * @return read only document */ @Override public INI merge(MergeMode mergeMode, INI... others) { var newDoc = INI.create(); for(var other : others) { merge(mergeMode, newDoc, other); } return newDoc; } protected void merge(MergeMode mergeMode, AbstractData newDoc, AbstractData other) { newDoc.values.putAll(other.values); for(var sec : other.sections.entrySet()) { switch(mergeMode) { case FLATTEN_SECTIONS: break; default: throw new UnsupportedOperationException(); } // if(newDoc.sections.containsKey(sec.getKey())) { // merge(newDoc.sections.get(sec.getKey()), sec.getValue()); // } // else { // // } } } } /** * Parse a file that contains a document in INI format if it exists. If the * file does not exists, a new writable document will be returned. If it does exist, * it will have case insensitive keys for values and sections, and insertion order will be * preserved. * * @return document */ public static INI fromFileIfExists(Path file) { if(Files.exists(file)) { try (var in = Files.newBufferedReader(file)) { return fromReader(in); } catch (IOException ioe) { throw new UncheckedIOException(ioe); } } else { return create(); } } /** * Parse a stream that contains a document in INI format. It will have case * insensitive keys for values and sections, and insertion order will be * preserved. * * @param reader reader * @return document */ public static INI fromReader(Reader reader) { try { return new INIReader.Builder().build().read(reader); } catch (IOException ioe) { throw new UncheckedIOException(ioe); } catch (ParseException e) { throw new IllegalStateException("Failed to parse.", e); } } /** * Parse a stream that contains a document in INI format. It will have case * insensitive keys for values and sections, and insertion order will be * preserved. * * @param in stream * @return document */ public static INI fromInput(InputStream in) { try { return new INIReader.Builder().build().read(new InputStreamReader(in)); } catch (IOException ioe) { throw new UncheckedIOException(ioe); } catch (ParseException e) { throw new IllegalStateException("Failed to parse.", e); } } /** * Parse a string that contains a document in INI format. It will have case * insensitive keys for values and sections, and insertion order will be * preserved. * * @return document */ public static INI fromString(String content) { return fromReader(new StringReader(content)); } static abstract class AbstractIO { protected final char sectionPathSeparator; protected final char valueSeparator; protected final char commentCharacter; protected final boolean lineContinuations; protected final boolean valueSeparatorWhitespace; protected final boolean trimmedValue; protected final MultiValueMode multiValueMode; protected final char multiValueSeparator; protected final boolean emptyValues; protected final EscapeMode escapeMode; AbstractIO(AbstractIOBuilder builder) { this.sectionPathSeparator = builder.sectionPathSeparator; this.valueSeparator = builder.valueSeparator; this.commentCharacter = builder.commentCharacter; this.lineContinuations = builder.lineContinuations; this.valueSeparatorWhitespace = builder.valueSeparatorWhitespace; this.trimmedValue = builder.trimmedValue; this.multiValueMode = builder.multiValueMode; this.multiValueSeparator = builder.multiValueSeparator; this.emptyValues = builder.emptyValues; this.escapeMode = builder.escapeMode; } } static abstract class AbstractIOBuilder> { char sectionPathSeparator = '.'; boolean lineContinuations = true; boolean valueSeparatorWhitespace = true; char valueSeparator = '='; char commentCharacter = ';'; boolean trimmedValue = true; MultiValueMode multiValueMode = MultiValueMode.SEPARATED; char multiValueSeparator = ','; boolean emptyValues = true; EscapeMode escapeMode = EscapeMode.QUOTED; /** * Configure how the write behaves when writing special characters in strings * with regard to escaping. See {@link EscapeQuoteMode}. * * @param escapeMode escape mode. * @return this for chaining */ @SuppressWarnings("unchecked") public B withEscapeMode(EscapeMode escapeMode) { this.escapeMode = escapeMode; return (B)this; } /** * Do not allow empty values. When reading the content, if a key's value is * empty the key will be entirely ignored. When writing the content, if a key's * value is empty neither key nor value will be written. *

* By default empty values are allowed. * * @return this for chaining */ public final B withoutEmptyValues() { return withEmptyValues(false); } /** * Configure whether to allow empty values. *

* When true and reading the content, if a key's value is empty the * key will be stored with an empty value. When writing the content, if a key's * value is empty it will be written with it's key, but no value. *

* When false and reading the content, if a key's value is empty * the key will be entirely ignored. When writing the content, if a key's value * is empty neither key nor value will be written. * * @param emptyValues allow empty values * @return this for chaining */ @SuppressWarnings("unchecked") public final B withEmptyValues(boolean emptyValues) { this.emptyValues = emptyValues; return (B) this; } /** * Configure how to behave when duplicate value keys are encountered. See * {@link MultiValueMode}. * * @param multiValueMode mode * @return this for chaining */ @SuppressWarnings("unchecked") public final B withMultiValueMode(MultiValueMode multiValueMode) { this.multiValueMode = multiValueMode; return (B) this; } /** * Configure the separator character to use when outputting multiple values and * {@link MultiValueMode#SEPARATED} is in use. * * @param multiValueSeparator separator * @return this for chaining */ @SuppressWarnings("unchecked") public final B withMultiValueSeparator(char multiValueSeparator) { this.multiValueSeparator = multiValueSeparator; return (B) this; } /** * Configure the separator character to use to express nested sections. * * @param sectionPathSeparator separator * @return this for chaining */ @SuppressWarnings("unchecked") public final B withSectionPathSeparator(char sectionPathSeparator) { this.sectionPathSeparator = sectionPathSeparator; return (B) this; } /** * By default, whitespace is trimmed from the start and end of values. This will * prevent that, leaving any whitespace intact. * * @return this for chaining */ public final B withoutTrimmedValue() { return withTrimmedValue(false); } /** * Configure whether whitespace is trimmed from the start and end of values. * When true, whitespace will be trimmed, when false> * whitespace will be left intact. * * @param trimmedValue trim whitespace from value * @return this for chaining */ @SuppressWarnings("unchecked") public final B withTrimmedValue(boolean trimmedValue) { this.trimmedValue = trimmedValue; return (B) this; } /** * Do not expect or output whitespace either side of the value separator. I.e. * by default you would expect Key1 = Value1. Using this method * would result in Key1=Value. * * @return this for chaining */ public final B withoutValueSeparatorWhitespace() { return withValueSeparatorWhitespace(false); } /** * Configure whether to expect or output whitespace either side of the value * separator. I.e. when true a value would be parsed and output as * Key1 = Value1. When false a value would be parsed * and out as Key1=Value. * * @param valueSeparatorWhitespace value separator whitespace * @return this for chaining */ @SuppressWarnings("unchecked") public final B withValueSeparatorWhitespace(boolean valueSeparatorWhitespace) { this.valueSeparatorWhitespace = valueSeparatorWhitespace; return (B) this; } /** * Configure to not allow line continuations. When the line continuation * character is encountered, it will simply be ignored. * * @return this for chaining */ public final B withoutLineContinuations() { return withLineContinuations(false); } /** * Configure whether to allow line continuations. If a line ends with the * continuation character (a \), the text starting from the first * non-whitespace character of the next line will be treated as part of the same * value. This will continue until a line doesn't end with the continuation * character. * * @param lineContinuations allow line continuations * @return this for chaining */ @SuppressWarnings("unchecked") public final B withLineContinuations(boolean lineContinuations) { this.lineContinuations = lineContinuations; return (B) this; } /** * Set the character to use for value separator. By default this is a * =. * * @param valueSeparator value separator * @return this for chaining */ @SuppressWarnings("unchecked") public final B withValueSeparator(char valueSeparator) { this.valueSeparator = valueSeparator; return (B) this; } /** * Set the character to use for line comments. By default this is a * ;. * * @param valueSeparator value separator * @return this for chaining */ @SuppressWarnings("unchecked") public final B withCommentCharacter(char commentCharacter) { this.commentCharacter = commentCharacter; return (B) this; } } /** * INI documents may contain (potentially nested) sections that can be used to * group command value keys. *

* A section is generally introduced by using the pattern * [SectionName]. To express a nested section, the path to the * section is used instead with each element separated by some character * (. by default), for example [Level1.Level2.Level3]. */ public interface Section extends Data { /** * Remove this section from it's parent section or document. */ void remove(); /** * Get the key used for this section. * * @return key */ String key(); /** * Return all parent paths up to but excluding the root document. * * @return parents */ Section[] parents(); /** * Return this sections path, with each element in the array being an element of * the path starting from the root document. * * @return path */ String[] path(); /** * Get the parent {@link Section} or thrown an {@link IllegalArgumentException} * if this section is in the root document. * * @return parent */ Section parent(); } final static class SectionImpl extends AbstractData implements Section { private final String key; private final Optional parent; private final INI ini; SectionImpl(boolean emptyValues, boolean preserveOrder, boolean caseSensitiveKeys, boolean caseSensitiveSections, Map values, Map sections, Data parent, String key, Optional interpolator, Optional variablePattern, MissingVariableMode missingVariableMode) { super(emptyValues, preserveOrder, caseSensitiveKeys, caseSensitiveSections, values, sections, interpolator, variablePattern, missingVariableMode); this.parent = Optional.of(parent); this.key = key; Data p = parent; INI ini; while (true) { if (p instanceof INI) { ini = (INI) p; break; } else p = ((Section) p).parent(); } this.ini = ini; } SectionImpl(boolean emptyValues, boolean preserveOrder, boolean caseSensitiveKeys, boolean caseSenstiveSections, Data parent, String key, Optional interpolator, Optional variablePattern, MissingVariableMode missingVariableMode) { super(emptyValues, preserveOrder, caseSensitiveKeys, caseSenstiveSections, interpolator, variablePattern, missingVariableMode); this.parent = Optional.of(parent); this.key = key; Data p = parent; INI ini; while (true) { if (p instanceof INI) { ini = (INI) p; break; } else p = ((SectionImpl) p).parent.get(); } this.ini = ini; } /** * A read-only facade to this section. */ @Override public Section readOnly() { var s = new HashMap(); sections.forEach((k, v) -> s.put(k, Arrays.asList(v).stream().map(vv -> vv.readOnly()) .collect(Collectors.toList()).toArray(new Section[0]))); return new SectionImpl(emptyValues, preserveOrder, caseSensitiveKeys, caseSensitiveSections, Collections.unmodifiableMap(values), Collections.unmodifiableMap(s), parent.get(), key, interpolator, variablePattern, missingVariableMode); } /** * Remove this section from it's parent section or document. */ @Override public void remove() { ((AbstractData) parent.get()).remove(this); } /** * Get the root document {@link INI} this section is part of. * * @return document */ @Override public INI document() { return ini; } /** * Get the parent {@link Section} or thrown an {@link IllegalArgumentException} * if this section is in the root document. * * @return parent */ @Override public Section parent() { return parentOr().orElseThrow(() -> new IllegalStateException("Has no parent.")); } /** * Return this sections path, with each element in the array being an element of * the path starting from the root document. * * @return path */ @Override public String[] path() { Section s = this; var l = new ArrayList<>(Arrays.asList(key())); while (s.parentOr().isPresent()) { if(s.parentOr().get() instanceof Section) { s = s.parentOr().get(); l.add(s.key()); } else break; } Collections.reverse(l); return l.toArray(new String[0]); } /** * Return all parent paths up to but excluding the root document. * * @return parents */ @Override public Section[] parents() { Section s = this; var l = new ArrayList

(); while (s.parentOr().isPresent()) { if(s.parentOr().get() instanceof Section) { s = s.parentOr().get(); l.add(s); } else break; } return l.toArray(new Section[0]); } /** * Get the optional parent. If this section is part of the root document, * {@link Optional#isEmpty()} will return true. * * @return optional parent */ @Override public Optional
parentOr() { return parent.get() instanceof Section ? parent.map(d -> (Section) d) : Optional.empty(); } /** * Get the key used for this section. * * @return key */ @Override public String key() { return key; } void merge(Map values) { this.values.putAll(values); } @Override public Map sections() { return sections; } @Override protected void fireUpdated(AbstractData parent, String key, String[] was, String[] newVals) { super.fireUpdated(parent, key, was, newVals); this.parent.ifPresent(p -> ((AbstractData)p).fireUpdated(parent, key, was, newVals)); } @Override protected void fireSectionUpdate(AbstractData parent, Section newSection, UpdateType type) { super.fireSectionUpdate(parent, newSection, type); this.parent.ifPresent(p -> ((AbstractData)p).fireSectionUpdate(parent, newSection, type)); } } public enum MergeMode { FLATTEN_SECTIONS } INI merge(MergeMode mergeMode, INI... others); @Override INI readOnly(); /* * Copyright 2002-2021 the original author or authors. * * 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 * * https://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. */ /** * {@link LinkedHashMap} variant that stores String keys in a case-insensitive * manner, for example for key-based access in a results table. * *

* Preserves the original order as well as the original casing of keys, while * allowing for contains, get and remove calls with any case of key. * *

* Does not support {@code null} keys. * * @author Juergen Hoeller * @author Phillip Webb * @since 3.0 * @param the value type */ @SuppressWarnings("serial") public static class LinkedCaseInsensitiveMap implements Map, Serializable, Cloneable { /** * Default load factor for {@link HashMap}/{@link LinkedHashMap} variants. * * @see #newHashMap(int) * @see #newLinkedHashMap(int) */ static final float DEFAULT_LOAD_FACTOR = 0.75f; /** * Instantiate a new {@link HashMap} with an initial capacity that can * accommodate the specified number of elements without any immediate * resize/rehash operations to be expected. *

* This differs from the regular {@link HashMap} constructor which takes an * initial capacity relative to a load factor but is effectively aligned with * the JDK's * {@link java.util.concurrent.ConcurrentHashMap#ConcurrentHashMap(int)}. * * @param expectedSize the expected number of elements (with a corresponding * capacity to be derived so that no resize/rehash * operations are needed) * @since 5.3 * @see #newLinkedHashMap(int) */ public static HashMap newHashMap(int expectedSize) { return new HashMap<>(computeMapInitialCapacity(expectedSize), DEFAULT_LOAD_FACTOR); } private static int computeMapInitialCapacity(int expectedSize) { return (int) Math.ceil(expectedSize / (double) DEFAULT_LOAD_FACTOR); } private final LinkedHashMap targetMap; private final HashMap caseInsensitiveKeys; private final Locale locale; private transient volatile Set keySet; private transient volatile Collection values; private transient volatile Set> entrySet; /** * Create a new LinkedCaseInsensitiveMap that stores case-insensitive keys * according to the default Locale (by default in lower case). * * @see #convertKey(String) */ public LinkedCaseInsensitiveMap() { this((Locale) null); } /** * Create a new LinkedCaseInsensitiveMap that stores case-insensitive keys * according to the given Locale (in lower case). * * @param locale the Locale to use for case-insensitive key conversion * @see #convertKey(String) */ public LinkedCaseInsensitiveMap(Locale locale) { this(12, locale); // equivalent to LinkedHashMap's initial capacity of 16 } /** * Create a new LinkedCaseInsensitiveMap that wraps a {@link LinkedHashMap} with * an initial capacity that can accommodate the specified number of elements * without any immediate resize/rehash operations to be expected, storing * case-insensitive keys according to the default Locale (in lower case). * * @param expectedSize the expected number of elements (with a corresponding * capacity to be derived so that no resize/rehash * operations are needed) * @see CollectionUtils#newHashMap(int) * @see #convertKey(String) */ public LinkedCaseInsensitiveMap(int expectedSize) { this(expectedSize, null); } /** * Create a new LinkedCaseInsensitiveMap that wraps a {@link LinkedHashMap} with * an initial capacity that can accommodate the specified number of elements * without any immediate resize/rehash operations to be expected, storing * case-insensitive keys according to the given Locale (in lower case). * * @param expectedSize the expected number of elements (with a corresponding * capacity to be derived so that no resize/rehash * operations are needed) * @param locale the Locale to use for case-insensitive key conversion * @see CollectionUtils#newHashMap(int) * @see #convertKey(String) */ public LinkedCaseInsensitiveMap(int expectedSize, Locale locale) { this.targetMap = new LinkedHashMap<>((int) (expectedSize / DEFAULT_LOAD_FACTOR), DEFAULT_LOAD_FACTOR) { @Override public boolean containsKey(Object key) { return LinkedCaseInsensitiveMap.this.containsKey(key); } @Override protected boolean removeEldestEntry(Map.Entry eldest) { boolean doRemove = LinkedCaseInsensitiveMap.this.removeEldestEntry(eldest); if (doRemove) { removeCaseInsensitiveKey(eldest.getKey()); } return doRemove; } }; this.caseInsensitiveKeys = newHashMap(expectedSize); this.locale = (locale != null ? locale : Locale.getDefault()); } /** * Copy constructor. */ @SuppressWarnings("unchecked") private LinkedCaseInsensitiveMap(LinkedCaseInsensitiveMap other) { this.targetMap = (LinkedHashMap) other.targetMap.clone(); this.caseInsensitiveKeys = (HashMap) other.caseInsensitiveKeys.clone(); this.locale = other.locale; } // Implementation of java.util.Map @Override public int size() { return this.targetMap.size(); } @Override public boolean isEmpty() { return this.targetMap.isEmpty(); } @Override public boolean containsKey(Object key) { return (key instanceof String && this.caseInsensitiveKeys.containsKey(convertKey((String) key))); } @Override public boolean containsValue(Object value) { return this.targetMap.containsValue(value); } @Override public V get(Object key) { if (key instanceof String) { String caseInsensitiveKey = this.caseInsensitiveKeys.get(convertKey((String) key)); if (caseInsensitiveKey != null) { return this.targetMap.get(caseInsensitiveKey); } } return null; } @Override public V getOrDefault(Object key, V defaultValue) { if (key instanceof String) { String caseInsensitiveKey = this.caseInsensitiveKeys.get(convertKey((String) key)); if (caseInsensitiveKey != null) { return this.targetMap.get(caseInsensitiveKey); } } return defaultValue; } @Override public V put(String key, V value) { String oldKey = this.caseInsensitiveKeys.put(convertKey(key), key); V oldKeyValue = null; if (oldKey != null && !oldKey.equals(key)) { oldKeyValue = this.targetMap.remove(oldKey); } V oldValue = this.targetMap.put(key, value); return (oldKeyValue != null ? oldKeyValue : oldValue); } @Override public void putAll(Map map) { if (map.isEmpty()) { return; } map.forEach(this::put); } @Override public V putIfAbsent(String key, V value) { String oldKey = this.caseInsensitiveKeys.putIfAbsent(convertKey(key), key); if (oldKey != null) { V oldKeyValue = this.targetMap.get(oldKey); if (oldKeyValue != null) { return oldKeyValue; } else { key = oldKey; } } return this.targetMap.putIfAbsent(key, value); } @Override public V computeIfAbsent(String key, Function mappingFunction) { String oldKey = this.caseInsensitiveKeys.putIfAbsent(convertKey(key), key); if (oldKey != null) { V oldKeyValue = this.targetMap.get(oldKey); if (oldKeyValue != null) { return oldKeyValue; } else { key = oldKey; } } return this.targetMap.computeIfAbsent(key, mappingFunction); } @Override public V remove(Object key) { if (key instanceof String) { String caseInsensitiveKey = removeCaseInsensitiveKey((String) key); if (caseInsensitiveKey != null) { return this.targetMap.remove(caseInsensitiveKey); } } return null; } @Override public void clear() { this.caseInsensitiveKeys.clear(); this.targetMap.clear(); } @Override public Set keySet() { Set keySet = this.keySet; if (keySet == null) { keySet = new KeySet(this.targetMap.keySet()); this.keySet = keySet; } return keySet; } @Override public Collection values() { Collection values = this.values; if (values == null) { values = new Values(this.targetMap.values()); this.values = values; } return values; } @Override public Set> entrySet() { Set> entrySet = this.entrySet; if (entrySet == null) { entrySet = new EntrySet(this.targetMap.entrySet()); this.entrySet = entrySet; } return entrySet; } @Override public LinkedCaseInsensitiveMap clone() { return new LinkedCaseInsensitiveMap<>(this); } @Override public boolean equals(Object other) { return (this == other || this.targetMap.equals(other)); } @Override public int hashCode() { return this.targetMap.hashCode(); } @Override public String toString() { return this.targetMap.toString(); } // Specific to LinkedCaseInsensitiveMap /** * Return the locale used by this {@code LinkedCaseInsensitiveMap}. Used for * case-insensitive key conversion. * * @since 4.3.10 * @see #LinkedCaseInsensitiveMap(Locale) * @see #convertKey(String) */ public Locale getLocale() { return this.locale; } /** * Convert the given key to a case-insensitive key. *

* The default implementation converts the key to lower-case according to this * Map's Locale. * * @param key the user-specified key * @return the key to use for storing * @see String#toLowerCase(Locale) */ protected String convertKey(String key) { return key.toLowerCase(getLocale()); } /** * Determine whether this map should remove the given eldest entry. * * @param eldest the candidate entry * @return {@code true} for removing it, {@code false} for keeping it * @see LinkedHashMap#removeEldestEntry */ protected boolean removeEldestEntry(Map.Entry eldest) { return false; } private String removeCaseInsensitiveKey(String key) { return this.caseInsensitiveKeys.remove(convertKey(key)); } private class KeySet extends AbstractSet { private final Set delegate; KeySet(Set delegate) { this.delegate = delegate; } @Override public int size() { return this.delegate.size(); } @Override public boolean contains(Object o) { return this.delegate.contains(o); } @Override public Iterator iterator() { return new KeySetIterator(); } @Override public boolean remove(Object o) { return LinkedCaseInsensitiveMap.this.remove(o) != null; } @Override public void clear() { LinkedCaseInsensitiveMap.this.clear(); } @Override public Spliterator spliterator() { return this.delegate.spliterator(); } @Override public void forEach(Consumer action) { this.delegate.forEach(action); } } private class Values extends AbstractCollection { private final Collection delegate; Values(Collection delegate) { this.delegate = delegate; } @Override public int size() { return this.delegate.size(); } @Override public boolean contains(Object o) { return this.delegate.contains(o); } @Override public Iterator iterator() { return new ValuesIterator(); } @Override public void clear() { LinkedCaseInsensitiveMap.this.clear(); } @Override public Spliterator spliterator() { return this.delegate.spliterator(); } @Override public void forEach(Consumer action) { this.delegate.forEach(action); } } private class EntrySet extends AbstractSet> { private final Set> delegate; public EntrySet(Set> delegate) { this.delegate = delegate; } @Override public int size() { return this.delegate.size(); } @Override public boolean contains(Object o) { return this.delegate.contains(o); } @Override public Iterator> iterator() { return new EntrySetIterator(); } @Override @SuppressWarnings("unchecked") public boolean remove(Object o) { if (this.delegate.remove(o)) { removeCaseInsensitiveKey(((Map.Entry) o).getKey()); return true; } return false; } @Override public void clear() { this.delegate.clear(); caseInsensitiveKeys.clear(); } @Override public Spliterator> spliterator() { return this.delegate.spliterator(); } @Override public void forEach(Consumer> action) { this.delegate.forEach(action); } } private abstract class EntryIterator implements Iterator { private final Iterator> delegate; private Entry last; public EntryIterator() { this.delegate = targetMap.entrySet().iterator(); } protected Entry nextEntry() { Entry entry = this.delegate.next(); this.last = entry; return entry; } @Override public boolean hasNext() { return this.delegate.hasNext(); } @Override public void remove() { this.delegate.remove(); if (this.last != null) { removeCaseInsensitiveKey(this.last.getKey()); this.last = null; } } } private class KeySetIterator extends EntryIterator { @Override public String next() { return nextEntry().getKey(); } } private class ValuesIterator extends EntryIterator { @Override public V next() { return nextEntry().getValue(); } } private class EntrySetIterator extends EntryIterator> { @Override public Entry next() { return nextEntry(); } } } }