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() {