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));

Reply via email to