You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@commons.apache.org by gg...@apache.org on 2026/10/16 14:30:02 UTC

(commons-lang) branch master updated: Fix unmatched-quote handling in ExtendedMessageFormat

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 5b3a81dcd Fix unmatched-quote handling in ExtendedMessageFormat
5b3a81dcd is described below

commit 5b3a81dcd5e4c8bcee87b5bb07f4c0672be48545
Author: Gary Gregory <ga...@gmail.com>
AuthorDate: Fri Oct 16 10:29:52 2026 -0400

    Fix unmatched-quote handling in ExtendedMessageFormat
    
    This is PR 1819 plus more work.
    
    Treat an unmatched single quote as closed at the end of the pattern,
    matching MessageFormat and ExtendedMessageFormat without a registry.
    Previously, supplying a non-null registry caused these patterns to
    throw IllegalArgumentException.
    
    Document quoting semantics in the class Javadoc, including doubled
    single quotes and literal format elements within quoted text. Clarify
    that implicit quote closure does not close an unfinished format element.
    
    Add regression coverage for unmatched and trailing quotes, custom
    formats, empty-registry parity with MessageFormat, and toPattern()
    round-tripping. Verify that unmatched quotes inside unfinished format
    descriptions still cause IllegalArgumentException.
---
 .../commons/lang3/text/ExtendedMessageFormat.java  | 20 +++++----
 .../lang3/text/ExtendedMessageFormatTest.java      | 47 ++++++++++++++++++++++
 2 files changed, 60 insertions(+), 7 deletions(-)

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 140de752d..e9a88330b 100644
--- a/src/main/java/org/apache/commons/lang3/text/ExtendedMessageFormat.java
+++ b/src/main/java/org/apache/commons/lang3/text/ExtendedMessageFormat.java
@@ -55,6 +55,9 @@
  * found is used for this format element.
  * </p>
  *
+ * <p>Quoting follows {@link MessageFormat}: doubled single quotes represent a literal single quote, and an unmatched single quote is treated as closed
+ * at the end of the pattern. Format elements within quoted text are treated as literal text.</p>
+ *
  * <p>
  * <strong>NOTICE:</strong> The various subformat mutator methods are considered unnecessary; they exist on the parent
  * class to allow the type of customization which it is the job of this class to provide in
@@ -145,12 +148,14 @@ public ExtendedMessageFormat(final String pattern, final Map<String, ? extends F
     }
 
     /**
-     * Consume a quoted string, adding it to {@code appendTo} if
-     * specified.
+     * Consumes a quoted string, adding it to {@code appendTo} if specified.
+     * <p>
+     * An unmatched quote is treated as if it were closed at the end of the pattern, as by {@link MessageFormat}. This does not close an unfinished format
+     * element.
+     * </p>
      *
-     * @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 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}
      */
@@ -174,8 +179,9 @@ private StringBuilder appendQuotedString(final char[] pattern, final ParsePositi
             }
             next(pos);
         }
-        throw new IllegalArgumentException(
-                "Unterminated quoted string at position " + start);
+        // MessageFormat treats an unmatched quote as closed at the end of the pattern
+        return appendTo == null ? null : appendTo.append(pattern, start,
+                pos.getIndex() - start);
     }
 
     /**
diff --git a/src/test/java/org/apache/commons/lang3/text/ExtendedMessageFormatTest.java b/src/test/java/org/apache/commons/lang3/text/ExtendedMessageFormatTest.java
index 16bd98864..34c60f487 100644
--- a/src/test/java/org/apache/commons/lang3/text/ExtendedMessageFormatTest.java
+++ b/src/test/java/org/apache/commons/lang3/text/ExtendedMessageFormatTest.java
@@ -39,6 +39,7 @@
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
 import org.junitpioneer.jupiter.cartesian.ArgumentSets;
 import org.junitpioneer.jupiter.cartesian.CartesianTest;
 
@@ -546,4 +547,50 @@ void testTruncatedFormatElementWithRegistry() {
         assertThrowsExactly(IllegalArgumentException.class, () -> new ExtendedMessageFormat("{0}extra{", new HashMap<>()));
     }
 
+    /**
+     * Test that an unmatched quote is treated as closed at the end of the pattern,
+     * as by java.text.MessageFormat.
+     */
+    @Test
+    void testUnmatchedQuote() {
+        final ExtendedMessageFormat emf = new ExtendedMessageFormat("it's a {0,lower} test!", registry);
+        assertEquals("its a {0,lower} test!", emf.format(new Object[] {"DUMMY"}));
+        assertEquals(emf.format(new Object[] {"DUMMY"}),
+                new ExtendedMessageFormat(emf.toPattern(), registry).format(new Object[] {"DUMMY"}));
+
+        final ExtendedMessageFormat emf2 = new ExtendedMessageFormat("Don{0,upper}t panic: '", registry);
+        assertEquals("DonDUMMYt panic: ", emf2.format(new Object[] {"Dummy"}));
+        assertEquals("Don{0,upper}t panic: ", emf2.toPattern());
+        assertEquals(emf2.format(new Object[] {"Dummy"}),
+                new ExtendedMessageFormat(emf2.toPattern(), registry).format(new Object[] {"Dummy"}));
+    }
+
+    /**
+     * Checks that an unmatched quote does not make an unfinished format element valid.
+     *
+     * @param pattern pattern containing an unfinished format element
+     */
+    @ParameterizedTest
+    @ValueSource(strings = {"{0,number,0'foo}", "{0,date,yyyy'foo}", "{0,choice,0#'foo}", "{0,upper,'foo}"})
+    void testUnmatchedQuoteInFormatDescription(final String pattern) {
+        assertThrowsExactly(IllegalArgumentException.class, () -> new ExtendedMessageFormat(pattern, registry));
+    }
+
+    /**
+     * Checks unmatched quotes against MessageFormat with an empty registry, including pattern round-tripping.
+     *
+     * @param pattern pattern containing an unmatched quote
+     */
+    @ParameterizedTest
+    @ValueSource(strings = {"Don't panic {0}", "'", "'''", "prefix '", "'{0}", "{0} suffix '{1}"})
+    void testUnmatchedQuoteWithEmptyRegistry(final String pattern) {
+        final Object[] args = {"Dummy", "Other"};
+        final MessageFormat expected = new MessageFormat(pattern, Locale.ROOT);
+        final ExtendedMessageFormat actual = new ExtendedMessageFormat(pattern, Locale.ROOT, Collections.emptyMap());
+        assertEquals(expected.format(args), actual.format(args));
+        assertEquals(expected.toPattern(), actual.toPattern());
+        final ExtendedMessageFormat reconstructed = new ExtendedMessageFormat(actual.toPattern(), Locale.ROOT, Collections.emptyMap());
+        assertEquals(actual.format(args), reconstructed.format(args));
+    }
+
 }