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());
}