diff --git a/src/main/java/org/apache/commons/collections4/map/CaseInsensitiveMap.java b/src/main/java/org/apache/commons/collections4/map/CaseInsensitiveMap.java
index b2c6d24f81..5d19b46a63 100644
--- a/src/main/java/org/apache/commons/collections4/map/CaseInsensitiveMap.java
+++ b/src/main/java/org/apache/commons/collections4/map/CaseInsensitiveMap.java
@@ -25,9 +25,12 @@
/**
* A case-insensitive {@code Map}.
*
- * Before keys are added to the map or compared to other existing keys, they are converted
- * to all lowercase in a locale-independent fashion by using information from the Unicode
- * data file.
+ * Before keys are added to the map or compared to other existing keys, each of their
+ * characters (Unicode code points) is converted with
+ * {@code Character.toLowerCase(Character.toUpperCase(codePoint))}, that is with the simple,
+ * one-to-one case mappings of the Unicode data file, independently of the locale. This is not
+ * the same as {@link String#toLowerCase(java.util.Locale)}: for example, U+03D0 (GREEK BETA
+ * SYMBOL) and U+03B2 (GREEK SMALL LETTER BETA) give the same key.
*
*
* Null keys are supported.
@@ -131,8 +134,8 @@ public CaseInsensitiveMap clone() {
}
/**
- * Overrides convertKey() from {@link AbstractHashedMap} to convert keys to
- * lower case.
+ * Overrides convertKey() from {@link AbstractHashedMap} to convert each code point of
+ * keys with {@code Character.toLowerCase(Character.toUpperCase(codePoint))}.
*
* Returns {@link AbstractHashedMap#NULL} if key is null.
*
@@ -142,11 +145,14 @@ public CaseInsensitiveMap clone() {
@Override
protected Object convertKey(final Object key) {
if (key != null) {
- final char[] chars = key.toString().toCharArray();
- for (int i = chars.length - 1; i >= 0; i--) {
- chars[i] = Character.toLowerCase(Character.toUpperCase(chars[i]));
+ final String keyString = key.toString();
+ final StringBuilder converted = new StringBuilder(keyString.length());
+ for (int i = 0; i < keyString.length();) {
+ final int codePoint = keyString.codePointAt(i);
+ converted.appendCodePoint(Character.toLowerCase(Character.toUpperCase(codePoint)));
+ i += Character.charCount(codePoint);
}
- return new String(chars);
+ return converted.toString();
}
return NULL;
}
diff --git a/src/test/java/org/apache/commons/collections4/map/CaseInsensitiveMapTest.java b/src/test/java/org/apache/commons/collections4/map/CaseInsensitiveMapTest.java
index 6339938ab3..b68ed06b9f 100644
--- a/src/test/java/org/apache/commons/collections4/map/CaseInsensitiveMapTest.java
+++ b/src/test/java/org/apache/commons/collections4/map/CaseInsensitiveMapTest.java
@@ -22,7 +22,9 @@
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.InvalidObjectException;
+import java.util.Arrays;
import java.util.HashMap;
+import java.util.HashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
@@ -93,6 +95,57 @@ void testInitialCapacityZero() {
assertEquals(1, map.data.length);
}
+ /**
+ * Test for COLLECTIONS-899:
+ * characters outside the Basic Multilingual Plane are converted like the other characters.
+ */
+ @Test
+ void testSupplementaryCharacters() throws Exception {
+ final String capitalLongI = new String(Character.toChars(0x10400)); // DESERET CAPITAL LETTER LONG I
+ final String smallLongI = new String(Character.toChars(0x10428)); // DESERET SMALL LETTER LONG I
+ final String capitalLongE = new String(Character.toChars(0x10401)); // DESERET CAPITAL LETTER LONG E
+ final CaseInsensitiveMap map = new CaseInsensitiveMap<>();
+ map.put(capitalLongI, "value");
+ map.put("a" + capitalLongI + "B", "mixed");
+ assertEquals("value", map.get(smallLongI));
+ assertEquals("mixed", map.get("A" + smallLongI + "b"));
+ assertEquals(new HashSet<>(Arrays.asList(smallLongI, "a" + smallLongI + "b")), map.keySet());
+
+ map.put(smallLongI, "other value");
+ assertEquals(2, map.size());
+ assertEquals("other value", map.get(capitalLongI));
+ assertEquals("other value", serializeDeserialize(map).get(capitalLongI));
+
+ map.put(capitalLongE, "other letter");
+ assertEquals(3, map.size());
+ }
+
+ /**
+ * Characters without case, and unpaired surrogates, are kept as they are.
+ */
+ @Test
+ void testUncasedSupplementaryCharactersAndUnpairedSurrogates() {
+ final String grinningFace = new String(Character.toChars(0x1F600));
+ final CaseInsensitiveMap map = new CaseInsensitiveMap<>();
+ map.put(grinningFace, "emoji");
+ map.put("\uD801", "high surrogate");
+ map.put("x\uDC28", "low surrogate");
+ assertEquals(new HashSet<>(Arrays.asList(grinningFace, "\uD801", "x\uDC28")), map.keySet());
+ }
+
+ /**
+ * Keys are converted again when a map is deserialized, so a converted key must convert to itself.
+ */
+ @Test
+ void testConversionIsIdempotent() {
+ final CaseInsensitiveMap map = new CaseInsensitiveMap<>();
+ for (int codePoint = 0; codePoint <= Character.MAX_CODE_POINT; codePoint++) {
+ final int current = codePoint;
+ final Object converted = map.convertKey(new String(Character.toChars(current)));
+ assertEquals(converted, map.convertKey(converted), () -> "U+" + Integer.toHexString(current));
+ }
+ }
+
// COLLECTIONS-294
@Test
void testLocaleIndependence() {