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
commit 66f93099008bfe6485164166e4c0d70e5dade96a Author: Gary Gregory <[email protected]> AuthorDate: Thu Oct 15 14:51:41 2026 -0400 Use standalone month names for month-only patterns in FastDatePrinter (#1815) Create CalendarUtils only when a rule needs standalone month names. Clarify the month-only pattern Javadoc and expand regression coverage for Java 8, numeric months, literals, and mixed month fields. Use a Cartesian test for locale, pattern, and month combinations. Validated with the default Maven build and focused tests on Java 8 and Java 21. --- .../apache/commons/lang3/time/FastDatePrinter.java | 70 +++++++------- .../commons/lang3/time/FastDatePrinterTest.java | 104 +++++++++++++++------ 2 files changed, 111 insertions(+), 63 deletions(-) diff --git a/src/main/java/org/apache/commons/lang3/time/FastDatePrinter.java b/src/main/java/org/apache/commons/lang3/time/FastDatePrinter.java index dbe482f69..ab42fab78 100644 --- a/src/main/java/org/apache/commons/lang3/time/FastDatePrinter.java +++ b/src/main/java/org/apache/commons/lang3/time/FastDatePrinter.java @@ -1440,6 +1440,39 @@ private void init() { maxLengthEstimate = len; } + /** + * Tests whether the pattern contains exactly one {@code M} field occurrence and no other fields. + * <p> + * Since Java 9, {@link SimpleDateFormat} formats the text month of such a pattern with standalone month names. In some languages, like the Slavic and + * Baltic ones, these have a different grammatical form from the names used next to a day ("{@code MMMM}" alone prints Russian February as nominative + * "{@code февраль}" where "{@code d MMMM}" prints genitive "{@code февраля}"). Quoted and unquoted literals are ignored, but repeated fields such as + * "{@code MMM MMM}" prevent standalone formatting. + * </p> + * + * @return Whether the pattern contains exactly one {@code M} field occurrence and no other fields. + */ + private boolean isMonthOnlyPattern() { + final int[] indexRef = new int[1]; + boolean monthField = false; + for (int i = 0; i < pattern.length(); i++) { + indexRef[0] = i; + final String token = parseToken(pattern, indexRef); + i = indexRef[0]; + if (token.isEmpty()) { + break; + } + final char c = token.charAt(0); + if (c == '\'') { + continue; + } + if (c != 'M' || monthField) { + return false; + } + monthField = true; + } + return monthField; + } + /** * Creates a new Calendar instance. * @@ -1468,7 +1501,7 @@ protected List<Rule> parsePattern() { final int length = pattern.length(); final int[] indexRef = new int[1]; final boolean monthOnly = STANDALONE_MONTH_PATTERN && isMonthOnlyPattern(); - final CalendarUtils calendarUtils = CalendarUtils.getInstance(locale); + CalendarUtils calendarUtils = null; for (int i = 0; i < length; i++) { indexRef[0] = i; @@ -1482,6 +1515,9 @@ protected List<Rule> parsePattern() { Rule rule; final char c = token.charAt(0); + if (calendarUtils == null && tokenLen >= 3 && (c == 'L' || c == 'M' && monthOnly)) { + calendarUtils = CalendarUtils.getInstance(locale); + } switch (c) { case 'G': // era designator (text) @@ -1598,38 +1634,6 @@ protected List<Rule> parsePattern() { return rules; } - /** - * Tests whether the month is the only field in the pattern. - * <p> - * {@link SimpleDateFormat} formats the text month of such a pattern with the standalone month names, which in some languages, like the Slavic and Baltic - * ones, are a different grammatical form from the names used next to a day ("{@code MMMM}" alone prints Russian February as nominative - * "{@code февраль}" where "{@code d MMMM}" prints genitive "{@code февраля}"). - * </p> - * - * @return Whether the pattern contains exactly one field, the month. - */ - private boolean isMonthOnlyPattern() { - final int[] indexRef = new int[1]; - boolean monthField = false; - for (int i = 0; i < pattern.length(); i++) { - indexRef[0] = i; - final String token = parseToken(pattern, indexRef); - i = indexRef[0]; - if (token.isEmpty()) { - break; - } - final char c = token.charAt(0); - if (c == '\'') { - continue; - } - if (c != 'M' || monthField) { - return false; - } - monthField = true; - } - return monthField; - } - /** * Performs the parsing of tokens. * diff --git a/src/test/java/org/apache/commons/lang3/time/FastDatePrinterTest.java b/src/test/java/org/apache/commons/lang3/time/FastDatePrinterTest.java index 3bdc7bc7b..0616e46a9 100644 --- a/src/test/java/org/apache/commons/lang3/time/FastDatePrinterTest.java +++ b/src/test/java/org/apache/commons/lang3/time/FastDatePrinterTest.java @@ -20,9 +20,11 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeFalse; import static org.junit.jupiter.api.Assumptions.assumeTrue; import java.io.Serializable; +import java.text.DateFormatSymbols; import java.text.FieldPosition; import java.text.ParseException; import java.text.SimpleDateFormat; @@ -39,6 +41,8 @@ import org.junit.jupiter.api.Test; import org.junitpioneer.jupiter.DefaultLocale; import org.junitpioneer.jupiter.DefaultTimeZone; +import org.junitpioneer.jupiter.cartesian.ArgumentSets; +import org.junitpioneer.jupiter.cartesian.CartesianTest; /** * Tests {@link FastDatePrinter}. @@ -80,6 +84,15 @@ private static Calendar initializeCalendar(final TimeZone tz) { return cal; } + static ArgumentSets testStandaloneMonthForMonthOnlyPatternFactory() { + // Month-only patterns, with or without literals, use the standalone form; patterns with another field keep the format form. + return ArgumentSets.argumentsForFirstParameter(Locale.US, Locale.GERMANY, new Locale("ru"), new Locale("cs"), new Locale("pl"), new Locale("fi")) + .argumentsForNextParameter("M", "MM", "MMM", "MMMM", "MMMMM", "'m:'MMMM", "MMMM''", "MMMM,", "MMMM-", "MMMM ", "d MMMM", "MMMM yyyy", + "MMM MMM", "MMM LLL", "MMMM LLLL", "LLL", "LLLL") + .argumentsForNextParameter(Calendar.JANUARY, Calendar.FEBRUARY, Calendar.MARCH, Calendar.APRIL, Calendar.MAY, Calendar.JUNE, Calendar.JULY, + Calendar.AUGUST, Calendar.SEPTEMBER, Calendar.OCTOBER, Calendar.NOVEMBER, Calendar.DECEMBER); + } + private DatePrinter getDateInstance(final int dateStyle, final Locale locale) { return getInstance(AbstractFormatCache.getPatternForStyle(Integer.valueOf(dateStyle), null, locale), TimeZone.getDefault(), Locale.getDefault()); } @@ -214,36 +227,6 @@ void testEraNonGregorianCalendar() { } } - /** - * Tests that a month-only pattern uses the standalone month names, like SimpleDateFormat. In some languages, like the Slavic and Baltic ones, they are a - * different grammatical form from the names used next to a day: "MMMM" alone prints Russian February as nominative "февраль" where "d MMMM" prints - * genitive "февраля". - */ - @Test - void testStandaloneMonthForMonthOnlyPattern() throws ParseException { - // SimpleDateFormat forces the standalone form for a month-only pattern only since Java 9 (JDK-8114833); Java 8 has no such rule. - assumeTrue(SystemUtils.isJavaVersionAtLeast(JavaVersion.JAVA_9)); - final Locale[] locales = { Locale.US, Locale.GERMANY, new Locale("ru"), new Locale("cs"), new Locale("pl"), new Locale("fi") }; - // Month-only patterns, with or without literals, use the standalone form; patterns with another field keep the format form. - final String[] patterns = { "MMM", "MMMM", "'m:'MMMM", "MMMM''", "MMMM,", "d MMMM", "MMMM yyyy", "MMM MMM" }; - for (final Locale locale : locales) { - for (final String pattern : patterns) { - for (int month = Calendar.JANUARY; month <= Calendar.DECEMBER; month++) { - final Calendar cal = Calendar.getInstance(TimeZones.GMT, locale); - cal.clear(); - cal.set(2024, month, 10); - final Date date = cal.getTime(); - final SimpleDateFormat sdf = new SimpleDateFormat(pattern, locale); - sdf.setTimeZone(TimeZones.GMT); - final FastDateFormat fdf = FastDateFormat.getInstance(pattern, TimeZones.GMT, locale); - assertEquals(sdf.format(date), fdf.format(date), () -> locale + " " + pattern); - // FastDateParser accepts all name styles, so the printer's output parses back. - assertEquals(month, DateUtils.toCalendar(fdf.parse(fdf.format(date)), TimeZones.GMT).get(Calendar.MONTH), () -> locale + " " + pattern); - } - } - } - } - @DefaultLocale(language = "en", country = "US") @DefaultTimeZone("America/New_York") @Test @@ -421,12 +404,46 @@ void testMilleniumBug() { assertEquals("01.01.1000", format.format(cal)); } + @Test + void testMonthOnlyPatternOnJava8() { + assumeFalse(SystemUtils.isJavaVersionAtLeast(JavaVersion.JAVA_9)); + final Locale[] locales = { Locale.US, Locale.GERMANY, new Locale("ru"), new Locale("cs"), new Locale("pl"), new Locale("fi") }; + for (final Locale locale : locales) { + final DateFormatSymbols symbols = new DateFormatSymbols(locale); + for (int month = Calendar.JANUARY; month <= Calendar.DECEMBER; month++) { + final Calendar cal = Calendar.getInstance(TimeZones.GMT, locale); + cal.clear(); + cal.set(2024, month, 10); + final Date date = cal.getTime(); + // Preserve the DateFormatSymbols names used before the Java 9 standalone-month rule. + assertEquals(symbols.getShortMonths()[month], getInstance("MMM", TimeZones.GMT, locale).format(date), () -> locale + " MMM"); + assertEquals(symbols.getMonths()[month], getInstance("MMMM", TimeZones.GMT, locale).format(date), () -> locale + " MMMM"); + assertEquals(symbols.getMonths()[month], getInstance("MMMMM", TimeZones.GMT, locale).format(date), () -> locale + " MMMMM"); + assertEquals(symbols.getMonths()[month] + ",", getInstance("MMMM,", TimeZones.GMT, locale).format(date), () -> locale + " MMMM,"); + } + } + } + @Test void testPatternMatches() { final DatePrinter printer = getInstance(YYYY_MM_DD); assertEquals(YYYY_MM_DD, printer.getPattern()); } + @Test + void testPatternsWithoutTextMonthFields() { + final Calendar cal = Calendar.getInstance(TimeZones.GMT, Locale.US); + cal.clear(); + cal.set(2024, Calendar.FEBRUARY, 10, 13, 45); + final Date date = cal.getTime(); + final String[] patterns = { "M", "MM", "L", "LL", "yyyy-MM-dd", "'MMMM'", "'abc'", "", "HH:mm", "yyyy-dd" }; + for (final String pattern : patterns) { + final SimpleDateFormat sdf = new SimpleDateFormat(pattern, Locale.US); + sdf.setTimeZone(TimeZones.GMT); + assertEquals(sdf.format(date), getInstance(pattern, TimeZones.GMT, Locale.US).format(date), () -> pattern); + } + } + /** * Test case for {@link FastDateParser#FastDateParser(String, TimeZone, Locale)}. */ @@ -460,6 +477,33 @@ void testSimpleDate() { assertEquals("0001/03/02", format.format(cal)); } + /** + * Tests that a month-only pattern uses the standalone month names, like SimpleDateFormat. In some languages, like the Slavic and Baltic ones, they are a + * different grammatical form from the names used next to a day: "MMMM" alone prints Russian February as nominative "февраль" where "d MMMM" prints + * genitive "февраля". + * + * @param locale the locale to test. + * @param pattern the date format pattern to test. + * @param month the calendar month to test. + * @throws ParseException if the formatted date cannot be parsed. + */ + @CartesianTest + @CartesianTest.MethodFactory("testStandaloneMonthForMonthOnlyPatternFactory") + void testStandaloneMonthForMonthOnlyPattern(final Locale locale, final String pattern, final int month) throws ParseException { + // SimpleDateFormat forces the standalone form for a month-only pattern only since Java 9 (JDK-8114833); Java 8 has no such rule. + assumeTrue(SystemUtils.isJavaVersionAtLeast(JavaVersion.JAVA_9)); + final Calendar cal = Calendar.getInstance(TimeZones.GMT, locale); + cal.clear(); + cal.set(2024, month, 10); + final Date date = cal.getTime(); + final SimpleDateFormat sdf = new SimpleDateFormat(pattern, locale); + sdf.setTimeZone(TimeZones.GMT); + final FastDateFormat fdf = FastDateFormat.getInstance(pattern, TimeZones.GMT, locale); + assertEquals(sdf.format(date), fdf.format(date), () -> locale + " " + pattern); + // FastDateParser accepts all name styles, so the printer's output parses back. + assertEquals(month, DateUtils.toCalendar(fdf.parse(fdf.format(date)), TimeZones.GMT).get(Calendar.MONTH), () -> locale + " " + pattern); + } + @SuppressWarnings("deprecation") @Test void testStringBufferOptions() {
