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 f4442b1c9 Keep FastDateFormat able to parse the '+hh:mm' offsets its 
own 'Z' patterns format (#1857)
f4442b1c9 is described below

commit f4442b1c9c02945fea6165a9011dd1671fe5bce5
Author: 陈志谦 <[email protected]>
AuthorDate: Tue Nov 3 06:09:00 2026 +0800

    Keep FastDateFormat able to parse the '+hh:mm' offsets its own 'Z' patterns 
format (#1857)
    
    FastDatePrinter formats three or more 'Z' pattern letters as the ISO 8601
    extended offset "+hh:mm" (FastDatePrinterTest#testTimeZoneAsZ pins this),
    but FastDateParser only accepted the colon-less RFC 822 form "+hhmm" for
    those widths. A FastDateFormat therefore failed to parse its own output:
    
      getInstance("yyyy-MM-ddZZZ").format(...) == "2023-10-27-04:00"
      parse("2023-10-27-04:00") -> null, error index 10
    
    For 'Z' with three or more letters, select a TimeZoneStrategy whose numeric
    offset alternative also accepts "+hh:mm"; FastTimeZone.getGmtTimeZone()
    already resolves the extended form. The variant is chosen only where the
    printer emits it: 'z' and one-letter 'Z' keep exact SimpleDateFormat parity
    (FastDateParserSDFTest passes unmodified), and two-letter 'Z' keeps its
    existing ISO 8601 strategy. The variant accepts a strict superset of the
    previous inputs and is cached per locale and cleared by 
FastDateParser.clear().
    
    New FastDateFormat_ZRoundTripTest fails on master and passes with this
    change; the FastDate* suites (60k+ checks) pass.
    
    Signed-off-by: simpleqt <[email protected]>
---
 .../apache/commons/lang3/time/FastDateParser.java  | 43 ++++++++++--
 .../lang3/time/FastDateFormat_ZRoundTripTest.java  | 78 ++++++++++++++++++++++
 .../commons/lang3/time/FastDateParserTest.java     |  4 +-
 3 files changed, 119 insertions(+), 6 deletions(-)

diff --git a/src/main/java/org/apache/commons/lang3/time/FastDateParser.java 
b/src/main/java/org/apache/commons/lang3/time/FastDateParser.java
index 887b6f8ab..0f0a177fc 100644
--- a/src/main/java/org/apache/commons/lang3/time/FastDateParser.java
+++ b/src/main/java/org/apache/commons/lang3/time/FastDateParser.java
@@ -527,8 +527,34 @@ public String toString() {
             }
         }
 
+        // The RFC 822 numeric offset form "+hhmm"; SimpleDateFormat rejects 
"+hh:mm" here, and so must 'z'.
         private static final String RFC_822_TIME_ZONE = 
"[+-](?:2[0-3]|[01]\\d)[0-5]\\d";
 
+        /**
+         * Same as {@link #RFC_822_TIME_ZONE} but also accepts the ISO 8601 
extended form {@code "+hh:mm"}.
+         */
+        private static final String ISO_8601_EXTENDED_TIME_ZONE = 
"[+-](?:2[0-3]|[01]\\d):?[0-5]\\d";
+
+        /**
+         * Cache of {@link TimeZoneStrategy} instances that accept the ISO 
8601 extended form, keyed by locale. Kept separate from the field caches 
because 'z'
+         * (and 'Z' with a single letter) must keep exact SimpleDateFormat 
parity and reject the extended form.
+         */
+        private static final ConcurrentMap<Locale, Strategy> 
ISO_EXTENDED_ZONE_CACHE = new ConcurrentHashMap<>(3);
+
+        static void clearIsoExtendedCache() {
+            ISO_EXTENDED_ZONE_CACHE.clear();
+        }
+
+        /**
+         * Gets a {@link TimeZoneStrategy} that also parses the ISO 8601 
extended offset form {@code "+hh:mm"}.
+         *
+         * @param locale The Locale.
+         * @return A shared strategy instance for the given Locale.
+         */
+        static Strategy getIsoExtendedStrategy(final Locale locale) {
+            return 
ISO_EXTENDED_ZONE_CACHE.computeIfAbsent(LocaleUtils.toLocale(locale), k -> new 
TimeZoneStrategy(k, true));
+        }
+
         private static final String GMT_OPTION = TimeZones.GMT_ID + 
"[+-]\\d{1,2}:\\d{2}";
 
         /**
@@ -563,12 +589,14 @@ static boolean skipTimeZone(final String tzId) {
         /**
          * Constructs a Strategy that parses a TimeZone.
          *
-         * @param locale The Locale.
+         * @param locale           The Locale.
+         * @param iso8601Extended Whether to also accept the ISO 8601 extended 
offset form {@code "+hh:mm"} that FastDatePrinter emits for three or more 'Z'
+         *                        pattern letters.
          */
-        TimeZoneStrategy(final Locale locale) {
+        TimeZoneStrategy(final Locale locale, final boolean iso8601Extended) {
             this.locale = LocaleUtils.toLocale(locale);
             final StringBuilder sb = new StringBuilder();
-            sb.append("((?iu)" + RFC_822_TIME_ZONE + "|" + GMT_OPTION);
+            sb.append("((?iu)" + (iso8601Extended ? 
ISO_8601_EXTENDED_TIME_ZONE : RFC_822_TIME_ZONE) + "|" + GMT_OPTION);
             final Set<String> sorted = new TreeSet<>(LONGER_FIRST_LOWERCASE);
             // Order is undefined.
             // TODO Use of getZoneStrings() is discouraged per its Javadoc.
@@ -863,6 +891,7 @@ private static Map<String, Integer> 
appendDisplayNames(final Calendar calendar,
      */
     static void clear() {
         
Stream.of(CACHES).filter(Objects::nonNull).forEach(ConcurrentMap::clear);
+        TimeZoneStrategy.clearIsoExtendedCache();
     }
 
     /**
@@ -1064,7 +1093,8 @@ public Locale getLocale() {
      */
     private Strategy getLocaleSpecificStrategy(final int field, final Calendar 
definingCalendar) {
         return getCache(field).computeIfAbsent(locale,
-                k -> field == Calendar.ZONE_OFFSET ? new 
TimeZoneStrategy(locale) : new CaseInsensitiveTextStrategy(field, 
definingCalendar, locale));
+                k -> field == Calendar.ZONE_OFFSET ? new 
TimeZoneStrategy(locale, false)
+                        : new CaseInsensitiveTextStrategy(field, 
definingCalendar, locale));
     }
 
     /*
@@ -1144,6 +1174,11 @@ private Strategy getStrategy(final char f, final int 
width, final Calendar defin
             if (width == 2) {
                 return ISO8601TimeZoneStrategy.ISO_8601_3_STRATEGY;
             }
+            if (width >= 3) {
+                // FastDatePrinter formats three or more 'Z' as the ISO 8601 
extended offset "+hh:mm", so parsing
+                // 'Z' must accept that form as well. 'z' keeps exact 
SimpleDateFormat parity and rejects it.
+                return TimeZoneStrategy.getIsoExtendedStrategy(locale);
+            }
             // falls-through
         case 'z':
             return getLocaleSpecificStrategy(Calendar.ZONE_OFFSET, 
definingCalendar);
diff --git 
a/src/test/java/org/apache/commons/lang3/time/FastDateFormat_ZRoundTripTest.java
 
b/src/test/java/org/apache/commons/lang3/time/FastDateFormat_ZRoundTripTest.java
new file mode 100644
index 000000000..b22ea9f4e
--- /dev/null
+++ 
b/src/test/java/org/apache/commons/lang3/time/FastDateFormat_ZRoundTripTest.java
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.lang3.time;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.text.ParsePosition;
+import java.util.Date;
+import java.util.Locale;
+import java.util.TimeZone;
+
+import org.apache.commons.lang3.AbstractLangTest;
+import org.junit.jupiter.api.Test;
+import org.junitpioneer.jupiter.ReadsDefaultLocale;
+import org.junitpioneer.jupiter.ReadsDefaultTimeZone;
+
+/**
+ * Tests that {@link FastDateFormat} can parse what its own 'Z' patterns 
format: FastDatePrinter emits the
+ * ISO 8601 extended offset form "+hh:mm" for a pattern with two, or three or 
more, 'Z' letters, so the
+ * parser side must accept that form.
+ */
+class FastDateFormat_ZRoundTripTest extends AbstractLangTest {
+
+    private static final TimeZone NEW_YORK = 
TimeZones.getTimeZone("America/New_York");
+
+    private static Date parse(final String pattern, final String text) {
+        final ParsePosition parsePosition = new ParsePosition(0);
+        final Date date = FastDateFormat.getInstance(pattern, NEW_YORK, 
Locale.US).parse(text, parsePosition);
+        assertNotNull(date, () -> pattern + " failed to parse '" + text + "' 
(error index " + parsePosition.getErrorIndex() + ")");
+        return date;
+    }
+
+    @Test
+    @ReadsDefaultLocale
+    @ReadsDefaultTimeZone
+    void testParseExplicitColonOffset() {
+        // Same instant as the known-good 'ZZ' parsing of the identical text.
+        assertEquals(parse("yyyy-MM-ddZZ", "2023-10-27-05:30"), 
parse("yyyy-MM-ddZZZ", "2023-10-27-05:30"));
+    }
+
+    @Test
+    @ReadsDefaultLocale
+    @ReadsDefaultTimeZone
+    void testRoundTripOneAndTwoZ() {
+        final Date date = new Date(1698446400000L);
+        assertEquals("2023-10-27-0400", 
FastDateFormat.getInstance("yyyy-MM-ddZ", NEW_YORK, Locale.US).format(date));
+        assertEquals("2023-10-27-04:00", 
FastDateFormat.getInstance("yyyy-MM-ddZZ", NEW_YORK, Locale.US).format(date));
+        assertEquals(parse("yyyy-MM-ddZZ", "2023-10-27-04:00"), 
parse("yyyy-MM-ddZ", "2023-10-27-0400"));
+    }
+
+    @Test
+    @ReadsDefaultLocale
+    @ReadsDefaultTimeZone
+    void testRoundTripThreeOrMoreZ() {
+        for (int zCount = 3; zCount <= 6; zCount++) {
+            final String pattern = "yyyy-MM-dd" + "ZZZZZZZZ".substring(0, 
zCount);
+            final FastDateFormat format = FastDateFormat.getInstance(pattern, 
NEW_YORK, Locale.US);
+            final String text = format.format(new Date(1698446400000L));
+            assertEquals("2023-10-27-04:00", text, pattern);
+            assertEquals(parse("yyyy-MM-ddZZ", "2023-10-27-04:00"), 
parse(pattern, text), pattern);
+        }
+    }
+}
diff --git 
a/src/test/java/org/apache/commons/lang3/time/FastDateParserTest.java 
b/src/test/java/org/apache/commons/lang3/time/FastDateParserTest.java
index a14352c2e..87b1aaaec 100644
--- a/src/test/java/org/apache/commons/lang3/time/FastDateParserTest.java
+++ b/src/test/java/org/apache/commons/lang3/time/FastDateParserTest.java
@@ -636,9 +636,9 @@ void testParseMissingTimeZoneName() throws 
ReflectiveOperationException {
         final Object cachedStrategy = FieldUtils.readField(zonePattern, 
"strategy", true);
         // The report supplies no concrete regex/TreeMap mismatch. Simulate a 
matched name missing
         // from the lookup using a private strategy instance, leaving the 
shared cache untouched.
-        final Constructor<?> constructor = 
cachedStrategy.getClass().getDeclaredConstructor(Locale.class);
+        final Constructor<?> constructor = 
cachedStrategy.getClass().getDeclaredConstructor(Locale.class, Boolean.TYPE);
         constructor.setAccessible(true);
-        final Object strategy = constructor.newInstance(Locale.US);
+        final Object strategy = constructor.newInstance(Locale.US, 
Boolean.FALSE);
         final Map<?, ?> names = (Map<?, ?>) FieldUtils.readField(strategy, 
"tzNames", true);
         assertNotNull(names.remove("PST"));
         FieldUtils.writeField(zonePattern, "strategy", strategy, true);

Reply via email to