This is an automated email from the ASF dual-hosted git repository.
garydgregory pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-lang.git
The following commit(s) were added to refs/heads/master by this push:
new 75d85a807 Keep abbreviate and truncate off surrogate pair boundaries
(#1719)
75d85a807 is described below
commit 75d85a807d7b66da432a822de6fc75c48134decf
Author: alhuda <[email protected]>
AuthorDate: Sat Jun 20 22:28:57 2026 +0530
Keep abbreviate and truncate off surrogate pair boundaries (#1719)
* keep abbreviate and truncate off surrogate pair boundaries
* Add grin-only surrogate assertions to abbreviate and truncate tests
---
.../java/org/apache/commons/lang3/StringUtils.java | 43 +++++++++++++++++++---
.../commons/lang3/StringUtilsAbbreviateTest.java | 34 +++++++++++++++++
.../org/apache/commons/lang3/StringUtilsTest.java | 20 ++++++++++
3 files changed, 91 insertions(+), 6 deletions(-)
diff --git a/src/main/java/org/apache/commons/lang3/StringUtils.java
b/src/main/java/org/apache/commons/lang3/StringUtils.java
index 7dfc11ec4..3853771b5 100644
--- a/src/main/java/org/apache/commons/lang3/StringUtils.java
+++ b/src/main/java/org/apache/commons/lang3/StringUtils.java
@@ -370,15 +370,39 @@ public static String abbreviate(final String str, String
abbrevMarker, final int
return str;
}
if (strLen - offset <= maxWidth - abbrevMarkerLength) {
- return abbrevMarker + str.substring(strLen - (maxWidth -
abbrevMarkerLength));
+ int tailStart = strLen - (maxWidth - abbrevMarkerLength);
+ if (splitsSurrogatePair(str, tailStart)) {
+ tailStart++;
+ }
+ return abbrevMarker + str.substring(tailStart);
}
if (offset <= abbrevMarkerLength + 1) {
- return str.substring(0, maxWidth - abbrevMarkerLength) +
abbrevMarker;
+ int headEnd = maxWidth - abbrevMarkerLength;
+ if (splitsSurrogatePair(str, headEnd)) {
+ headEnd--;
+ }
+ return str.substring(0, headEnd) + abbrevMarker;
}
if (maxWidth < minAbbrevWidthOffset) {
throw new IllegalArgumentException(String.format("Minimum
abbreviation width with offset is %d", minAbbrevWidthOffset));
}
- return abbrevMarker + abbreviate(str.substring(offset), abbrevMarker,
maxWidth - abbrevMarkerLength);
+ int from = offset;
+ if (splitsSurrogatePair(str, from)) {
+ from++;
+ }
+ return abbrevMarker + abbreviate(str.substring(from), abbrevMarker,
maxWidth - abbrevMarkerLength);
+ }
+
+ /**
+ * Tests whether a {@link String#substring} boundary at {@code index}
would fall between the two halves of a surrogate pair, that is the char before
+ * {@code index} is a high surrogate and the char at {@code index} is its
low surrogate. Slicing there leaves a lone surrogate in the result.
+ *
+ * @param str the String being sliced.
+ * @param index a candidate substring boundary, in {@code char} units.
+ * @return whether slicing at {@code index} would split a surrogate pair.
+ */
+ private static boolean splitsSurrogatePair(final String str, final int
index) {
+ return index > 0 && index < str.length() &&
Character.isHighSurrogate(str.charAt(index - 1)) &&
Character.isLowSurrogate(str.charAt(index));
}
/**
@@ -8978,9 +9002,16 @@ public static String truncate(final String str, final
int offset, final int maxW
return null;
}
final int len = str.length();
- final int start = Math.min(offset, len);
- final int end = offset > len - maxWidth ? len : offset + maxWidth;
- return str.substring(start, Math.min(end, len));
+ int start = Math.min(offset, len);
+ int end = Math.min(offset > len - maxWidth ? len : offset + maxWidth,
len);
+ // keep both edges off the middle of a surrogate pair so the result is
never left holding a lone surrogate
+ if (splitsSurrogatePair(str, start)) {
+ start++;
+ }
+ if (splitsSurrogatePair(str, end)) {
+ end--;
+ }
+ return str.substring(start, Math.max(start, end));
}
/**
diff --git
a/src/test/java/org/apache/commons/lang3/StringUtilsAbbreviateTest.java
b/src/test/java/org/apache/commons/lang3/StringUtilsAbbreviateTest.java
index 3f753ba19..670e5fb00 100644
--- a/src/test/java/org/apache/commons/lang3/StringUtilsAbbreviateTest.java
+++ b/src/test/java/org/apache/commons/lang3/StringUtilsAbbreviateTest.java
@@ -19,6 +19,7 @@
import static
org.apache.commons.lang3.LangAssertions.assertIllegalArgumentException;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -170,6 +171,39 @@ void testAbbreviate_StringStringIntInt() {
assertEquals("....fg", StringUtils.abbreviate("abcdefg", "....", 5,
6));
}
+ @Test
+ void testAbbreviateSurrogatePair() {
+ // U+1F600 GRINNING FACE is a single supplementary code point stored
as a surrogate pair
+ final String grin = "😀";
+ // the head cut backs off the pair so the marker is never preceded by
a lone high surrogate
+ assertEquals("...", StringUtils.abbreviate(grin + "abcdef", 4));
+ assertEquals(grin + "...", StringUtils.abbreviate(grin + "abcdef", 5));
+ // a trailing supplementary code point is kept whole rather than
sliced into a lone low surrogate
+ assertEquals("..." + grin, StringUtils.abbreviate("abcdef" + grin, 6,
5));
+ // an input that is only the pair is kept whole or dropped, never
split into a lone surrogate
+ assertEquals(grin, StringUtils.abbreviate(grin, 4));
+ assertEquals(grin, StringUtils.abbreviate(grin, 0, 5));
+ assertEquals(grin, StringUtils.abbreviate(grin, 1, 4));
+ assertEquals(grin, StringUtils.abbreviate(grin, "x", 0, 2));
+ assertEquals(grin, StringUtils.abbreviate(grin, "x", 1, 2));
+ assertEquals(grin, StringUtils.abbreviate(grin, "", 0, 2));
+ assertEquals("", StringUtils.abbreviate(grin, "", 0, 1));
+ // results stay within maxWidth and never contain an unpaired surrogate
+ for (int width = 4; width <= 8; width++) {
+ final String result = StringUtils.abbreviate("a" + grin + "b" +
grin + "cd", width);
+ assertTrue(result.length() <= width, () -> "result longer than
maxWidth: " + result);
+ for (int i = 0; i < result.length(); i++) {
+ final char ch = result.charAt(i);
+ if (Character.isHighSurrogate(ch)) {
+ assertTrue(i + 1 < result.length() &&
Character.isLowSurrogate(result.charAt(i + 1)), "lone high surrogate in: " +
result);
+ i++; // skip the paired low surrogate
+ } else {
+ assertFalse(Character.isLowSurrogate(ch), "lone low
surrogate in: " + result);
+ }
+ }
+ }
+ }
+
// Fixed LANG-1463
@Test
void testAbbreviateMarkerWithEmptyString() {
diff --git a/src/test/java/org/apache/commons/lang3/StringUtilsTest.java
b/src/test/java/org/apache/commons/lang3/StringUtilsTest.java
index 578d3117f..369e9bb41 100644
--- a/src/test/java/org/apache/commons/lang3/StringUtilsTest.java
+++ b/src/test/java/org/apache/commons/lang3/StringUtilsTest.java
@@ -3054,6 +3054,26 @@ void testTruncate_StringIntInt() {
assertEquals("", StringUtils.truncate("abcdefghijklmno",
Integer.MAX_VALUE, Integer.MAX_VALUE));
}
+ @Test
+ void testTruncate_StringIntInt_surrogatePair() {
+ // U+1F600 GRINNING FACE is a single supplementary code point stored
as a surrogate pair
+ final String grin = "😀";
+ // a cut that would land between the two halves keeps the result well
formed instead of emitting a lone surrogate
+ assertEquals("a", StringUtils.truncate("a" + grin + "b", 0, 2));
+ assertEquals(grin, StringUtils.truncate("a" + grin + "b", 1, 2));
+ assertEquals("ab", StringUtils.truncate("ab" + grin, 0, 3));
+ // an offset that lands inside a pair skips the orphaned low surrogate
+ assertEquals("a", StringUtils.truncate(grin + "ab", 1, 2));
+ // an input that is only the pair stays whole or drops to empty, never
a lone surrogate
+ assertEquals(grin, StringUtils.truncate(grin, 0, 2));
+ assertEquals(grin, StringUtils.truncate(grin, 0, 3));
+ assertEquals("", StringUtils.truncate(grin, 0, 0));
+ assertEquals("", StringUtils.truncate(grin, 0, 1));
+ assertEquals("", StringUtils.truncate(grin, 1, 1));
+ assertEquals("", StringUtils.truncate(grin, 1, 2));
+ assertEquals("", StringUtils.truncate(grin, 2, 2));
+ }
+
@Test
void testUnCapitalize() {
assertNull(StringUtils.uncapitalize(null));