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 dd78aebd1 FastDateParser parses 'Y' (week year) as plain calendar 
year; asymmetric with FastDatePrinter and with SimpleDateFormat; boundary dates 
shift by a full year, silently (f002).
dd78aebd1 is described below

commit dd78aebd126992294c68b0c775bd954f6140e5de
Author: Gary Gregory <[email protected]>
AuthorDate: Fri Sep 4 21:28:51 2026 -0400

    FastDateParser parses 'Y' (week year) as plain calendar year; asymmetric
    with FastDatePrinter and with SimpleDateFormat; boundary dates shift by
    a full year, silently (f002).
---
 src/changes/changes.xml                            |   1 +
 src/conf/spotbugs-exclude-filter.xml               |   5 +
 .../apache/commons/lang3/time/FastDateParser.java  | 102 ++++++++++++++++++++-
 .../commons/lang3/time/FastDateParserTest.java     |  23 +++++
 4 files changed, 128 insertions(+), 3 deletions(-)

diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index 7e11c17fb..77d7354d7 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -251,6 +251,7 @@ java.lang.NullPointerException: Cannot invoke
     <action                   type="fix" dev="ggregory" due-to="gaurav kumar 
pandey, Gary Gregory">Keep StringUtils left, right, mid, and overlay off 
surrogate pair boundaries (#1776).</action>
     <action                   type="fix" dev="ggregory" due-to="Gaurav Pandey, 
Gary Gregory">Align ReflectionDiffBuilder with AbstractReflection and add cycle 
detection to prevent StackOverflowError on cyclic object graphs.</action>
     <action                   type="fix" dev="ggregory" due-to="gaurav kumar 
pandey, Gary Gregory">Fix DurationFormatUtils.formatPeriod() calculation when 
pattern omits 'M' (#1780).</action>
+    <action                   type="fix" dev="ggregory" due-to="Gary 
Gregory">FastDateParser parses 'Y' (week year) as plain calendar year; 
asymmetric with FastDatePrinter and with SimpleDateFormat; boundary dates shift 
by a full year, silently (f002).</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/conf/spotbugs-exclude-filter.xml 
b/src/conf/spotbugs-exclude-filter.xml
index 4f47714ad..a07ceb9e9 100644
--- a/src/conf/spotbugs-exclude-filter.xml
+++ b/src/conf/spotbugs-exclude-filter.xml
@@ -182,6 +182,11 @@
     <Method name="appendFullDigits" params="java.lang.Appendable, int, int"/>
     <Bug pattern="SF_SWITCH_NO_DEFAULT" />
   </Match>
+  <Match>
+    <!-- equals/hashCode is never used -->
+    <Class 
name="org.apache.commons.lang3.time.FastDateParser$WeekDateRecorder"/>
+    <Bug pattern="EQ_DOESNT_OVERRIDE_EQUALS" />
+  </Match>
 
   <!-- Reason: The fallthrough on the switch statement is intentional -->
   <Match>
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 e8b6a5537..eadad25a1 100644
--- a/src/main/java/org/apache/commons/lang3/time/FastDateParser.java
+++ b/src/main/java/org/apache/commons/lang3/time/FastDateParser.java
@@ -28,6 +28,7 @@
 import java.util.Calendar;
 import java.util.Comparator;
 import java.util.Date;
+import java.util.GregorianCalendar;
 import java.util.HashMap;
 import java.util.List;
 import java.util.ListIterator;
@@ -629,6 +630,81 @@ public String toString() {
 
     }
 
+    /**
+     * A write-through recorder used while parsing a pattern that contains a 
week year ('Y'). Every mutation is delegated to the real target calendar
+     * unchanged, and the raw values assigned to the three week-date fields 
are additionally captured, so that after all fields are parsed the week date can
+     * be resolved from exactly what was parsed - mirroring {@code 
java.text.CalendarBuilder}, which {@link java.text.SimpleDateFormat} uses for 
the same
+     * purpose. (Reading the values back from the calendar instead would 
normalize them: {@link Calendar#get(int)} resolves the complete date, so a 
parsed
+     * week 53 read back through a calendar-year interpretation can roll the 
year and land a full year away.)
+     */
+    private static final class WeekDateRecorder extends GregorianCalendar {
+
+        private static final long serialVersionUID = 1L;
+
+        /** The calendar every mutation is delegated to. */
+        private final Calendar target;
+
+        private transient int weekYearValue;
+        private transient boolean weekYearSet;
+        private transient int weekOfYearValue;
+        private transient boolean weekOfYearSet;
+        private transient int dayOfWeekValue;
+        private transient boolean dayOfWeekSet;
+
+        WeekDateRecorder(final Calendar target) {
+            this.target = target;
+        }
+
+        /**
+         * Resolves the recorded week year through the target calendar's 
week-date machinery. The parsed 'Y' value was delegated into {@link 
Calendar#YEAR}
+         * by the number strategy; {@link Calendar#setWeekDate(int, int, int)} 
reinterprets it as a week year together with the parsed week of year and day
+         * of week, defaulting to week 1 and the calendar's first day-of-week 
when the pattern did not contain them (the same defaults as
+         * {@code java.text.CalendarBuilder}). The fields set by {@code 
setWeekDate} take precedence over any month/day fields parsed earlier, which 
matches
+         * {@link java.text.SimpleDateFormat}.
+         */
+        void applyWeekDate() {
+            if (weekYearSet) {
+                target.setWeekDate(weekYearValue, weekOfYearSet ? 
weekOfYearValue : 1, dayOfWeekSet ? dayOfWeekValue : 
target.getFirstDayOfWeek());
+            }
+        }
+
+        @Override
+        public void set(final int field, final int value) {
+            if (target == null) {
+                // Callers from the superclass constructors, before this 
recorder is fully constructed.
+                super.set(field, value);
+                return;
+            }
+            switch (field) {
+            case Calendar.YEAR:
+                weekYearValue = value;
+                weekYearSet = true;
+                break;
+            case Calendar.WEEK_OF_YEAR:
+                weekOfYearValue = value;
+                weekOfYearSet = true;
+                break;
+            case Calendar.DAY_OF_WEEK:
+                dayOfWeekValue = value;
+                dayOfWeekSet = true;
+                break;
+            default:
+                break;
+            }
+            target.set(field, value);
+        }
+
+        @Override
+        public void setTimeZone(final TimeZone zone) {
+            if (target == null) {
+                // Callers from the superclass constructors, before this 
recorder is fully constructed.
+                super.setTimeZone(zone);
+                return;
+            }
+            target.setTimeZone(zone);
+        }
+    }
+
     /**
      * Required for serialization support.
      *
@@ -638,14 +714,14 @@ public String toString() {
 
     static final Locale JAPANESE_IMPERIAL = new Locale("ja", "JP", "JP");
 
+    // helper classes to parse the format string
+
     /**
      * comparator used to sort regex alternatives. Alternatives should be 
ordered longer first, and shorter last. ('february' before 'feb'). All entries 
must be
      * lower-case by locale.
      */
     private static final Comparator<String> LONGER_FIRST_LOWERCASE = 
Comparator.reverseOrder();
 
-    // helper classes to parse the format string
-
     @SuppressWarnings("unchecked") // OK because we are creating an array with 
no entries
     private static final ConcurrentMap<Locale, Strategy>[] CACHES = new 
ConcurrentMap[Calendar.FIELD_COUNT];
 
@@ -808,6 +884,12 @@ private static StringBuilder simpleQuote(final 
StringBuilder sb, final String va
     /** Initialized from Calendar. */
     private transient List<StrategyAndWidth> patterns;
 
+    /**
+     * Whether the pattern contains a week-year field ('Y'). Derived from the 
pattern in {@link #init(Calendar)} (called from the constructor and from
+     * readObject), so it does not need to be serialized.
+     */
+    private transient volatile boolean weekYear;
+
     /**
      * Constructs a new FastDateParser.
      *
@@ -965,7 +1047,16 @@ private Strategy getStrategy(final char f, final int 
width, final Calendar defin
         case 'w':
             return WEEK_OF_YEAR_STRATEGY;
         case 'y':
+            return width > 2 ? LITERAL_YEAR_STRATEGY : 
ABBREVIATED_YEAR_STRATEGY;
         case 'Y':
+            // Week year: the number is parsed like a year (including the 
two-digit-century adjustment,
+            // as SimpleDateFormat does for 'YY'), but it must be resolved 
through the calendar's
+            // week-date machinery rather than Calendar.YEAR. Record that this 
pattern contains a week
+            // year; parse(String, ParsePosition, Calendar) re-resolves the 
date via setWeekDate,
+            // mirroring FastDatePrinter's WeekYear rule and 
java.text.CalendarBuilder. When the
+            // calendar does not support week dates, the value falls back to 
Calendar.YEAR, exactly
+            // like FastDatePrinter's fallback.
+            weekYear = true;
             return width > 2 ? LITERAL_YEAR_STRATEGY : 
ABBREVIATED_YEAR_STRATEGY;
         case 'X':
             return ISO8601TimeZoneStrategy.getStrategy(width);
@@ -1077,14 +1168,19 @@ public boolean parse(final String source, final 
ParsePosition pos, final Calenda
         if (!checkLength(source, pos)) {
             return false;
         }
+        final WeekDateRecorder recorder = weekYear && 
calendar.isWeekDateSupported() ? new WeekDateRecorder(calendar) : null;
+        final Calendar sink = recorder != null ? recorder : calendar;
         final ListIterator<StrategyAndWidth> lt = patterns.listIterator();
         while (lt.hasNext()) {
             final StrategyAndWidth strategyAndWidth = lt.next();
             final int maxWidth = strategyAndWidth.getMaxWidth(lt);
-            if (!strategyAndWidth.strategy.parse(this, calendar, source, pos, 
maxWidth)) {
+            if (!strategyAndWidth.strategy.parse(this, sink, source, pos, 
maxWidth)) {
                 return false;
             }
         }
+        if (recorder != null) {
+            recorder.applyWeekDate();
+        }
         return true;
     }
 
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 4c3c1538c..389e1e7cb 100644
--- a/src/test/java/org/apache/commons/lang3/time/FastDateParserTest.java
+++ b/src/test/java/org/apache/commons/lang3/time/FastDateParserTest.java
@@ -202,6 +202,29 @@ private Calendar getEraStart(int year, final TimeZone 
zone, final Locale locale)
         return cal;
     }
 
+    @Test
+    void testWeekYearParsing() throws ParseException {
+        // 'Y' must parse as a week year (resolved through 
Calendar.setWeekDate), matching both SimpleDateFormat
+        // and FastDatePrinter's WeekYear rule, instead of silently mapping to 
the plain calendar year.
+        final String[][] cases = {
+            { "YYYY-MM-dd", "2025-12-29" }, // the ubiquitous YYYY-for-yyyy 
slip, at a year boundary
+            { "YYYY-'W'ww-u", "2025-W01-1" },
+            { "YYYY-'W'ww-u", "2020-W53-5" },
+            { "YYYY-'W'ww", "2024-W15" },
+            { "YY-MM-dd", "25-12-29" },
+            { "YYYY", "2025" },
+            { "yyyy-MM-dd", "2024-12-29" } // plain calendar year is unaffected
+        };
+        for (final Locale locale : new Locale[] { Locale.US, Locale.GERMANY }) 
{
+            for (final String[] testCase : cases) {
+                final SimpleDateFormat sdf = new SimpleDateFormat(testCase[0], 
locale);
+                final DateParser fdp = getInstance(testCase[0], locale);
+                assertEquals(sdf.parse(testCase[1]), fdp.parse(testCase[1]),
+                        "Pattern " + testCase[0] + " input " + testCase[1] + " 
locale " + locale);
+            }
+        }
+    }
+
     DateParser getInstance(final String format) {
         return getInstance(null, format, TimeZone.getDefault(), 
Locale.getDefault());
     }

Reply via email to