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 4c9d42c15 ExtendedMessageFormat.applyPattern() is quadratic: full
pattern.toCharArray() per token (f008).
4c9d42c15 is described below
commit 4c9d42c157e41f5136f6729cebeb7fafa4abde5f
Author: Gary Gregory <[email protected]>
AuthorDate: Sat Sep 5 07:52:15 2026 -0400
ExtendedMessageFormat.applyPattern() is quadratic: full
pattern.toCharArray() per token (f008).
A 300 KB pattern drives ~180 GB of transient allocation where
java.text.MessageFormat parses in one linear pass (f008).
---
src/changes/changes.xml | 2 +
.../commons/lang3/text/ExtendedMessageFormat.java | 53 ++++++++++++----------
2 files changed, 30 insertions(+), 25 deletions(-)
diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index 2550f2532..ae949589c 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -256,6 +256,8 @@ java.lang.NullPointerException: Cannot invoke
<action type="fix" dev="ggregory" due-to="Gary
Gregory">StringUtils.replaceEachRepeatedly derives its recursion budget from
the input itself; the documented StackOverflowError protection fails on large
tables, and expanding rules amplify text 64x even at the default TTL
(f005).</action>
<action type="fix" dev="ggregory" due-to="Gary
Gregory">EventUtils.EventBindingInvocationHandler.invoke() dispatches
Object.hashCode/equals/toString into the bound business method
(empty-eventTypes default), or returns null -> NPE from hash collections
(non-empty) (f006).</action>
<action type="fix" dev="ggregory" due-to="Gary
Gregory">LocaleUtils static caches no longer grows on invalid input to
LocaleUtils.countriesByLanguage(String) (f007).</action>
+ <action type="fix" dev="ggregory" due-to="Gary
Gregory">LocaleUtils static caches no longer grows on invalid input to
LocaleUtils.countriesByLanguage(String) (f007).</action>
+ <action type="fix" dev="ggregory" due-to="Gary
Gregory">ExtendedMessageFormat.applyPattern() is quadratic: full
pattern.toCharArray() per token (f008).</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/ExtendedMessageFormat.java
b/src/main/java/org/apache/commons/lang3/text/ExtendedMessageFormat.java
index 74425e47d..738f369b1 100644
--- a/src/main/java/org/apache/commons/lang3/text/ExtendedMessageFormat.java
+++ b/src/main/java/org/apache/commons/lang3/text/ExtendedMessageFormat.java
@@ -145,14 +145,15 @@ public ExtendedMessageFormat(final String pattern, final
Map<String, ? extends F
* Consume a quoted string, adding it to {@code appendTo} if
* specified.
*
- * @param pattern pattern to parse
+ * @param pattern pattern to parse, as a char array created once by the
caller (avoids copying
+ * the entire pattern for every token parsed)
* @param pos current parse position
* @param appendTo optional StringBuilder to append
* @return {@code appendTo}
*/
- private StringBuilder appendQuotedString(final String pattern, final
ParsePosition pos,
+ private StringBuilder appendQuotedString(final char[] pattern, final
ParsePosition pos,
final StringBuilder appendTo) {
- assert pattern.toCharArray()[pos.getIndex()] == QUOTE :
+ assert pattern[pos.getIndex()] == QUOTE :
"Quoted string must start with quote character";
// handle quote character at the beginning of the string
@@ -162,11 +163,10 @@ private StringBuilder appendQuotedString(final String
pattern, final ParsePositi
next(pos);
final int start = pos.getIndex();
- final char[] c = pattern.toCharArray();
- for (int i = pos.getIndex(); i < pattern.length(); i++) {
- if (c[pos.getIndex()] == QUOTE) {
+ for (int i = pos.getIndex(); i < pattern.length; i++) {
+ if (pattern[pos.getIndex()] == QUOTE) {
next(pos);
- return appendTo == null ? null : appendTo.append(c, start,
+ return appendTo == null ? null : appendTo.append(pattern,
start,
pos.getIndex() - start);
}
next(pos);
@@ -197,19 +197,19 @@ public final void applyPattern(final String pattern) {
while (pos.getIndex() < pattern.length()) {
switch (c[pos.getIndex()]) {
case QUOTE:
- appendQuotedString(pattern, pos, stripCustom);
+ appendQuotedString(c, pos, stripCustom);
break;
case START_FE:
fmtCount++;
- seekNonWs(pattern, pos);
+ seekNonWs(c, pos);
final int start = pos.getIndex();
- final int index = readArgumentIndex(pattern, next(pos));
+ final int index = readArgumentIndex(pattern, c, next(pos));
stripCustom.append(START_FE).append(index);
- seekNonWs(pattern, pos);
+ seekNonWs(c, pos);
Format format = null;
String formatDescription = null;
if (c[pos.getIndex()] == START_FMT) {
- formatDescription = parseFormatDescription(pattern,
+ formatDescription = parseFormatDescription(pattern, c,
next(pos));
format = getFormat(formatDescription);
if (format == null) {
@@ -298,10 +298,10 @@ private Format getFormat(final String desc) {
/**
* Consume quoted string only
*
- * @param pattern pattern to parse
+ * @param pattern pattern to parse, as a char array created once by the
caller
* @param pos current parse position
*/
- private void getQuotedString(final String pattern, final ParsePosition
pos) {
+ private void getQuotedString(final char[] pattern, final ParsePosition
pos) {
appendQuotedString(pattern, pos, null);
}
@@ -325,17 +325,18 @@ private String insertFormats(final String pattern, final
ArrayList<String> custo
}
final StringBuilder sb = new StringBuilder(pattern.length() * 2);
final ParsePosition pos = new ParsePosition(0);
+ final char[] chars = pattern.toCharArray();
int fe = -1;
int depth = 0;
while (pos.getIndex() < pattern.length()) {
final char c = pattern.charAt(pos.getIndex());
switch (c) {
case QUOTE:
- appendQuotedString(pattern, pos, sb);
+ appendQuotedString(chars, pos, sb);
break;
case START_FE:
depth++;
- sb.append(START_FE).append(readArgumentIndex(pattern,
next(pos)));
+ sb.append(START_FE).append(readArgumentIndex(pattern, chars,
next(pos)));
// do not look for custom patterns when they are embedded,
e.g. in a choice
if (depth == 1) {
fe++;
@@ -371,12 +372,13 @@ private ParsePosition next(final ParsePosition pos) {
* Parse the format component of a format element.
*
* @param pattern string to parse
+ * @param chars the pattern as a char array created once by the caller
* @param pos current parse position
* @return Format description String
*/
- private String parseFormatDescription(final String pattern, final
ParsePosition pos) {
+ private String parseFormatDescription(final String pattern, final char[]
chars, final ParsePosition pos) {
final int start = pos.getIndex();
- seekNonWs(pattern, pos);
+ seekNonWs(chars, pos);
final int text = pos.getIndex();
int depth = 1;
while (pos.getIndex() < pattern.length()) {
@@ -393,7 +395,7 @@ private String parseFormatDescription(final String pattern,
final ParsePosition
next(pos);
break;
case QUOTE:
- getQuotedString(pattern, pos);
+ getQuotedString(chars, pos);
break;
default:
next(pos);
@@ -408,18 +410,19 @@ private String parseFormatDescription(final String
pattern, final ParsePosition
* Reads the argument index from the current format element
*
* @param pattern pattern to parse
+ * @param chars the pattern as a char array created once by the caller
* @param pos current parse position
* @return argument index
*/
- private int readArgumentIndex(final String pattern, final ParsePosition
pos) {
+ private int readArgumentIndex(final String pattern, final char[] chars,
final ParsePosition pos) {
final int start = pos.getIndex();
- seekNonWs(pattern, pos);
+ seekNonWs(chars, pos);
final StringBuilder result = new StringBuilder();
boolean error = false;
for (; !error && pos.getIndex() < pattern.length(); next(pos)) {
char c = pattern.charAt(pos.getIndex());
if (Character.isWhitespace(c)) {
- seekNonWs(pattern, pos);
+ seekNonWs(chars, pos);
if (pos.getIndex() >= pattern.length()) {
break;
}
@@ -449,11 +452,11 @@ private int readArgumentIndex(final String pattern, final
ParsePosition pos) {
/**
* Consume whitespace from the current parse position.
*
- * @param pattern String to read
+ * @param buffer the pattern to read, as a char array created once by the
caller (avoids
+ * copying the entire pattern on every call)
* @param pos current position
*/
- private void seekNonWs(final String pattern, final ParsePosition pos) {
- final char[] buffer = pattern.toCharArray();
+ private void seekNonWs(final char[] buffer, final ParsePosition pos) {
while (pos.getIndex() < buffer.length) {
final int len = StrMatcher.splitMatcher().isMatch(buffer,
pos.getIndex());
if (len == 0) {