You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@commons.apache.org by pk...@apache.org on 2026/05/17 21:27:37 UTC

(commons-lang) branch fix/pattern-length created (now 9c4422421)

This is an automated email from the ASF dual-hosted git repository.

ppkarwasz pushed a change to branch fix/pattern-length
in repository https://gitbox.apache.org/repos/asf/commons-lang.git


      at 9c4422421 Fix `WordUtils.wrap` for `wrapOn` matches different from one character

This branch includes the following new commits:

     new 9c4422421 Fix `WordUtils.wrap` for `wrapOn` matches different from one character

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(commons-lang) 01/01: Fix `WordUtils.wrap` for `wrapOn` matches different from one character

Posted by pk...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

ppkarwasz pushed a commit to branch fix/pattern-length
in repository https://gitbox.apache.org/repos/asf/commons-lang.git

commit 9c4422421b86fc72226f50d807cc392204a0d1a2
Author: Piotr P. Karwasz <pk...@apache.org>
AuthorDate: Sun May 17 23:19:26 2026 +0200

    Fix `WordUtils.wrap` for `wrapOn` matches different from one character
    
    `WordUtils.wrap` advanced the offset by a single character past each `wrapOn` match, assuming the separator is always exactly one character long:
    
     - When the `wrapOn` regex matched several characters, the surplus separator characters were left in the wrapped output instead of being consumed,
     - When the `wrapOn` regex matched zero characters, one additional character was consumed.
    
    Now it tracks the end offset of each match and resume from there, so that separators of any length, including zero-width matches, are skipped correctly.
---
 src/changes/changes.xml                            |  1 +
 .../org/apache/commons/lang3/text/WordUtils.java   | 21 +++---
 .../apache/commons/lang3/text/WordUtilsTest.java   | 82 ++++++++++++++--------
 3 files changed, 65 insertions(+), 39 deletions(-)

diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index 997c67b80..e0a55d869 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -152,6 +152,7 @@ java.lang.NullPointerException: Cannot invoke
     <action                   type="fix" dev="ggregory" due-to="Omkhar Arasaratnam, Gary Gregory">WordUtils.wrap() zero-width wrapOn regex match causes infinite loop (#1649).WordUtils.wrap() zero-width wrapOn regex match causes infinite loop (#1649).</action>
     <action                   type="fix" dev="ggregory" due-to="Omkhar Arasaratnam, Gary Gregory">HashCodeBuilder.append(Object) StackOverflowError on mutually-referential objects (#1650).</action>
     <action                   type="fix" dev="ggregory" due-to="Omkhar Arasaratnam, Gary Gregory">AtomicSafeInitializer.get() busy-spin without yield burns CPU during slow initialization (#1651).</action>
+    <action                   type="fix" dev="pkarwasz" due-to="Piotr Karwasz">WordUtils.wrap() leaves separator characters in the output when the wrapOn regex match is longer than one character (#1655).</action>
     <!-- ADD -->
     <action                   type="add" dev="ggregory" due-to="Gary Gregory">Add JavaVersion.JAVA_27.</action>
     <action                   type="add" dev="ggregory" due-to="Gary Gregory">Add SystemUtils.IS_JAVA_27.</action>
diff --git a/src/main/java/org/apache/commons/lang3/text/WordUtils.java b/src/main/java/org/apache/commons/lang3/text/WordUtils.java
index ae59a34e3..feed32d15 100644
--- a/src/main/java/org/apache/commons/lang3/text/WordUtils.java
+++ b/src/main/java/org/apache/commons/lang3/text/WordUtils.java
@@ -648,15 +648,17 @@ public static String wrap(final String str, int wrapLength, String newLineStr, f
 
         while (offset < inputLineLength) {
             int spaceToWrapAt = -1;
+            int endOfWrapAt = -1;
             Matcher matcher = patternToWrapOn.matcher(
                 str.substring(offset, Math.min((int) Math.min(Integer.MAX_VALUE, offset + wrapLength + 1L), inputLineLength)));
             if (matcher.find()) {
-                if (matcher.start() == 0) {
-                    // If the match is zero-width, advance by at least 1 to avoid infinite loop.
-                    offset += matcher.end() > 0 ? matcher.end() : 1;
+                spaceToWrapAt = matcher.start() + offset;
+                endOfWrapAt = matcher.end() + offset;
+                // Skip leading match, if it is not zero-width
+                if (spaceToWrapAt == offset && endOfWrapAt != offset) {
+                    offset = endOfWrapAt;
                     continue;
                 }
-                spaceToWrapAt = matcher.start() + offset;
             }
             // only last line without leading spaces is left
             if (inputLineLength - offset <= wrapLength) {
@@ -664,13 +666,13 @@ public static String wrap(final String str, int wrapLength, String newLineStr, f
             }
             while (matcher.find()) {
                 spaceToWrapAt = matcher.start() + offset;
+                endOfWrapAt = matcher.end() + offset;
             }
-            if (spaceToWrapAt >= offset) {
+            if (endOfWrapAt > offset) {
                 // normal case
                 wrappedLine.append(str, offset, spaceToWrapAt);
                 wrappedLine.append(newLineStr);
-                offset = spaceToWrapAt + 1;
-
+                offset = endOfWrapAt;
             } else // really long word or URL
             if (wrapLongWords) {
                 // wrap really long word one line at a time
@@ -680,14 +682,17 @@ public static String wrap(final String str, int wrapLength, String newLineStr, f
             } else {
                 // do not wrap really long word, just extend beyond limit
                 matcher = patternToWrapOn.matcher(str.substring(offset + wrapLength));
+                spaceToWrapAt = -1;
                 if (matcher.find()) {
                     spaceToWrapAt = matcher.start() + offset + wrapLength;
+                    endOfWrapAt = matcher.end() + offset + wrapLength;
                 }
 
                 if (spaceToWrapAt >= 0) {
                     wrappedLine.append(str, offset, spaceToWrapAt);
                     wrappedLine.append(newLineStr);
-                    offset = spaceToWrapAt + 1;
+                    // at least offset + wrapLength >= offset + 1
+                    offset = endOfWrapAt;
                 } else {
                     wrappedLine.append(str, offset, str.length());
                     offset = inputLineLength;
diff --git a/src/test/java/org/apache/commons/lang3/text/WordUtilsTest.java b/src/test/java/org/apache/commons/lang3/text/WordUtilsTest.java
index e14b14cbf..479b62640 100644
--- a/src/test/java/org/apache/commons/lang3/text/WordUtilsTest.java
+++ b/src/test/java/org/apache/commons/lang3/text/WordUtilsTest.java
@@ -20,15 +20,19 @@
 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.assertTimeout;
 import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.params.provider.Arguments.arguments;
 
 import java.lang.reflect.Constructor;
 import java.lang.reflect.Modifier;
-import java.time.Duration;
+import java.util.stream.Stream;
 
 import org.apache.commons.lang3.AbstractLangTest;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
 
 /**
  * Tests for WordUtils class.
@@ -36,6 +40,42 @@
 @Deprecated
 class WordUtilsTest extends AbstractLangTest {
 
+    static Stream<Arguments> testWrapStringIntStringBooleanString() {
+        return Stream.of(
+                // null passthrough
+                arguments(null, -1, false, "/", null),
+                // no changes test
+                arguments("flammable/inflammable", 30, false, "/", "flammable/inflammable"),
+                // wrap on / and small width
+                arguments("flammable/inflammable", 2, false, "/", "flammable\ninflammable"),
+                // wrap long words on / 1
+                arguments("flammable/inflammable", 9, true, "/", "flammable\ninflammab\nle"),
+                // wrap long words on / 2
+                arguments("flammable/inflammable", 15, true, "/", "flammable\ninflammable"),
+                // wrap long words on / 3
+                arguments("flammableinflammable", 15, true, "/", "flammableinflam\nmable"),
+                // default values
+                arguments("a/a/a/a", -1, false, "/", "a\na\na\na"),
+                arguments("a a a a", 1, false, null, "a\na\na\na"),
+                // strip leading / keep trailing
+                arguments("///abc///def///ghi", 3, false, "/", "abc\ndef\nghi"),
+                arguments("///abc///def///ghi", 4, false, "/", "abc/\ndef/\nghi"),
+                arguments("///abc///def///ghi", 5, false, "/", "abc//\ndef//\nghi"),
+                // keep only two trailing, wrap on third
+                arguments("///abc///def///ghi", 6, false, "/", "abc//\ndef//\nghi"),
+                // zero-width regex match must advance to avoid an infinite loop
+                arguments("abcabc", 3, false, "(?=a)", "abc\nabc"),
+                arguments("abcdefabcdef", 4, false, "(?=a)", "abcdef\nabcdef"),
+                arguments("abcdefabcdef", 4, true, "(?=a)", "abcd\nef\nabcd\nef"),
+                // width two regex
+                arguments("abc\\/abc", 3, false, "\\\\/", "abc\nabc"),
+                arguments("abcdef\\/abcdef", 4, false, "\\\\/", "abcdef\nabcdef"),
+                arguments("abcdef\\/abcdef", 4, true, "\\\\/", "abcd\nef\nabcd\nef"),
+                // variable-width regex
+                arguments(".abc.-def.--ghi", 5, false, "[.]-*", "abc\ndef\nghi")
+                );
+    }
+
     @Test
     void testCapitalize_String() {
         assertNull(WordUtils.capitalize(null));
@@ -406,34 +446,14 @@ void testWrap_StringIntStringBoolean() {
         assertEquals(expected, WordUtils.wrap(input, 20, "\n", true));
     }
 
-    @Test
-    void testWrap_StringIntStringBooleanString() {
-
-        //no changes test
-        String input = "flammable/inflammable";
-        String expected = "flammable/inflammable";
-        assertEquals(expected, WordUtils.wrap(input, 30, "\n", false, "/"));
-
-        // wrap on / and small width
-        expected = "flammable\ninflammable";
-        assertEquals(expected, WordUtils.wrap(input, 2, "\n", false, "/"));
-
-        // wrap long words on / 1
-        expected = "flammable\ninflammab\nle";
-        assertEquals(expected, WordUtils.wrap(input, 9, "\n", true, "/"));
-
-        // wrap long words on / 2
-        expected = "flammable\ninflammable";
-        assertEquals(expected, WordUtils.wrap(input, 15, "\n", true, "/"));
-
-        // wrap long words on / 3
-        input = "flammableinflammable";
-        expected = "flammableinflam\nmable";
-        assertEquals(expected, WordUtils.wrap(input, 15, "\n", true, "/"));
-    }
-
-    @Test
-    void testZeroWidthWrapOnRegex() {
-        assertTimeout(Duration.ofSeconds(2), () -> assertNotNull(WordUtils.wrap("abcdef", 3, "\n", false, "(?=a)")));
+    @ParameterizedTest
+    @MethodSource
+    @Timeout(2)
+    void testWrapStringIntStringBooleanString(final String str, final int wrapLength, final boolean wrapLongWords, final String wrapOn, final String expected) {
+        assertEquals(expected, WordUtils.wrap(str, wrapLength, "\n", wrapLongWords, wrapOn));
+        final String sep = System.lineSeparator();
+        if (!sep.equals("\n")) {
+            assertEquals(expected != null ? expected.replace("\n", sep) : null, WordUtils.wrap(str, wrapLength, null, wrapLongWords, wrapOn));
+        }
     }
 }