diff --git a/src/main/java/org/openrewrite/java/migrate/search/FindLocaleDateTimeFormats.java b/src/main/java/org/openrewrite/java/migrate/search/FindLocaleDateTimeFormats.java new file mode 100644 index 0000000000..5237465cf5 --- /dev/null +++ b/src/main/java/org/openrewrite/java/migrate/search/FindLocaleDateTimeFormats.java @@ -0,0 +1,99 @@ +/* + * Copyright 2026 the original author or authors. + *
+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *
+ * https://docs.moderne.io/licensing/moderne-source-available-license + *
+ * 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.openrewrite.java.migrate.search; + +import lombok.EqualsAndHashCode; +import lombok.Value; +import org.openrewrite.ExecutionContext; +import org.openrewrite.Preconditions; +import org.openrewrite.Recipe; +import org.openrewrite.TreeVisitor; +import org.openrewrite.java.JavaIsoVisitor; +import org.openrewrite.java.MethodMatcher; +import org.openrewrite.java.search.UsesMethod; +import org.openrewrite.java.tree.J; +import org.openrewrite.marker.SearchResult; + +import java.util.Arrays; +import java.util.List; + +/** + * Finds usages of locale-based date/time formatting APIs that may be affected by the + * JDK 20+ CLDR locale data changes. Starting with JDK 20, the Unicode CLDR 42 locale data + * changed the space character before AM/PM designators from a regular space to a narrow + * no-break space (NNBSP, \u202F). + *
+ * This can cause parsing issues when user input contains regular spaces but the formatter
+ * expects NNBSP. The affected APIs include locale-based DateFormat and DateTimeFormatter
+ * factory methods.
+ *
+ * @see JDK-8324308
+ * @see Unicode CLDR Version 42 Heads-up
+ */
+@EqualsAndHashCode(callSuper = false)
+@Value
+public class FindLocaleDateTimeFormats extends Recipe {
+
+ // DateFormat factory methods that return locale-sensitive formatters
+ private static final MethodMatcher DATE_FORMAT_GET_TIME_INSTANCE =
+ new MethodMatcher("java.text.DateFormat getTimeInstance(..)", true);
+ private static final MethodMatcher DATE_FORMAT_GET_DATE_TIME_INSTANCE =
+ new MethodMatcher("java.text.DateFormat getDateTimeInstance(..)", true);
+ private static final MethodMatcher DATE_FORMAT_GET_INSTANCE =
+ new MethodMatcher("java.text.DateFormat getInstance(..)", true);
+
+ // DateTimeFormatter factory methods that return locale-sensitive formatters
+ private static final MethodMatcher DTF_OF_LOCALIZED_TIME =
+ new MethodMatcher("java.time.format.DateTimeFormatter ofLocalizedTime(..)", true);
+ private static final MethodMatcher DTF_OF_LOCALIZED_DATE_TIME =
+ new MethodMatcher("java.time.format.DateTimeFormatter ofLocalizedDateTime(..)", true);
+
+ private static final List
+ * Licensed under the Moderne Source Available License (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://docs.moderne.io/licensing/moderne-source-available-license
+ *
+ * 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.openrewrite.java.migrate.search;
+
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.openrewrite.DocumentExample;
+import org.openrewrite.test.RecipeSpec;
+import org.openrewrite.test.RewriteTest;
+
+import static org.openrewrite.java.Assertions.java;
+
+class FindLocaleDateTimeFormatsTest implements RewriteTest {
+
+ @Override
+ public void defaults(RecipeSpec spec) {
+ spec.recipe(new FindLocaleDateTimeFormats());
+ }
+
+ @DocumentExample
+ @Test
+ void findDateFormatGetTimeInstance() {
+ rewriteRun(
+ //language=java
+ java(
+ """
+ import java.text.DateFormat;
+ import java.util.Date;
+
+ class Test {
+ void test() {
+ DateFormat df = DateFormat.getTimeInstance();
+ String formatted = df.format(new Date());
+ }
+ }
+ """,
+ """
+ import java.text.DateFormat;
+ import java.util.Date;
+
+ class Test {
+ void test() {
+ DateFormat df = /*~~(JDK 20+ CLDR: may use NNBSP before AM/PM)~~>*/DateFormat.getTimeInstance();
+ String formatted = df.format(new Date());
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @CsvSource(textBlock = """
+ DateFormat.getTimeInstance(DateFormat.SHORT)
+ DateFormat.getDateTimeInstance()
+ DateFormat.getInstance()
+ """)
+ @ParameterizedTest
+ void findDateFormatMethods(String methodCall) {
+ rewriteRun(
+ java(
+ """
+ import java.text.DateFormat;
+ import java.util.Date;
+
+ class Test {
+ void test() {
+ DateFormat df = %s;
+ String formatted = df.format(new Date());
+ }
+ }
+ """.formatted(methodCall),
+ """
+ import java.text.DateFormat;
+ import java.util.Date;
+
+ class Test {
+ void test() {
+ DateFormat df = /*~~(JDK 20+ CLDR: may use NNBSP before AM/PM)~~>*/%s;
+ String formatted = df.format(new Date());
+ }
+ }
+ """.formatted(methodCall)
+ )
+ );
+ }
+
+ @CsvSource(textBlock = """
+ 'DateTimeFormatter.ofLocalizedTime(FormatStyle.MEDIUM)', 'LocalTime.now()'
+ 'DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)', 'LocalDateTime.now()'
+ 'DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)', 'LocalDateTime.now()'
+ """)
+ @ParameterizedTest
+ void findDateTimeFormatterMethods(String methodCall, String formatArg) {
+ rewriteRun(
+ java(
+ """
+ import java.time.LocalDateTime;
+ import java.time.LocalTime;
+ import java.time.format.DateTimeFormatter;
+ import java.time.format.FormatStyle;
+
+ class Test {
+ void test() {
+ DateTimeFormatter dtf = %s;
+ String formatted = dtf.format(%s);
+ }
+ }
+ """.formatted(methodCall, formatArg),
+ """
+ import java.time.LocalDateTime;
+ import java.time.LocalTime;
+ import java.time.format.DateTimeFormatter;
+ import java.time.format.FormatStyle;
+
+ class Test {
+ void test() {
+ DateTimeFormatter dtf = /*~~(JDK 20+ CLDR: may use NNBSP before AM/PM)~~>*/%s;
+ String formatted = dtf.format(%s);
+ }
+ }
+ """.formatted(methodCall, formatArg)
+ )
+ );
+ }
+
+ @Test
+ void findMultipleUsages() {
+ rewriteRun(
+ //language=java
+ java(
+ """
+ import java.text.DateFormat;
+ import java.time.LocalTime;
+ import java.time.format.DateTimeFormatter;
+ import java.time.format.FormatStyle;
+ import java.util.Date;
+
+ class Test {
+ void test() {
+ DateFormat df1 = DateFormat.getTimeInstance();
+ DateFormat df2 = DateFormat.getDateTimeInstance();
+ DateTimeFormatter dtf = DateTimeFormatter.ofLocalizedTime(FormatStyle.MEDIUM);
+ }
+ }
+ """,
+ """
+ import java.text.DateFormat;
+ import java.time.LocalTime;
+ import java.time.format.DateTimeFormatter;
+ import java.time.format.FormatStyle;
+ import java.util.Date;
+
+ class Test {
+ void test() {
+ DateFormat df1 = /*~~(JDK 20+ CLDR: may use NNBSP before AM/PM)~~>*/DateFormat.getTimeInstance();
+ DateFormat df2 = /*~~(JDK 20+ CLDR: may use NNBSP before AM/PM)~~>*/DateFormat.getDateTimeInstance();
+ DateTimeFormatter dtf = /*~~(JDK 20+ CLDR: may use NNBSP before AM/PM)~~>*/DateTimeFormatter.ofLocalizedTime(FormatStyle.MEDIUM);
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Nested
+ class NoChange {
+ @Test
+ void noMatchForExplicitPattern() {
+ rewriteRun(
+ //language=java
+ java(
+ """
+ import java.time.LocalDateTime;
+ import java.time.format.DateTimeFormatter;
+
+ class Test {
+ void test() {
+ // Explicit patterns are not affected by CLDR changes
+ DateTimeFormatter dtf = DateTimeFormatter.ofPattern("HH:mm:ss a");
+ String formatted = dtf.format(LocalDateTime.now());
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void noMatchForDateFormatGetDateInstance() {
+ rewriteRun(
+ //language=java
+ java(
+ """
+ import java.text.DateFormat;
+ import java.util.Date;
+
+ class Test {
+ void test() {
+ // Date-only formatting doesn't include AM/PM
+ DateFormat df = DateFormat.getDateInstance();
+ String formatted = df.format(new Date());
+ }
+ }
+ """
+ )
+ );
+ }
+ }
+}