From 5faa57bb2d2bdb38d7f025880e2dd9fee704a22e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 01:22:11 +0530 Subject: [PATCH] datetimeformatter: DateTimeFormatter cheat sheet module (pattern letters incl. the four hour letters, yyyy vs YYYY week-year bug and WeekFields, en_IN locales and the U+202F AM/PM trap with parseLenient fix, SMART/STRICT/LENIENT parsing, ISO constants, SimpleDateFormat thread safety), 18 contract tests Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_017ic4b3fvyFpxThdRxcozLx --- README.md | 1 + datetimeformatter/README.md | 44 +++++ .../output/01-pattern-letters.txt | 61 ++++++ datetimeformatter/output/02-week-year-bug.txt | 35 ++++ datetimeformatter/output/03-locales.txt | 34 ++++ datetimeformatter/output/04-parsing.txt | 30 +++ datetimeformatter/output/05-iso-formats.txt | 19 ++ datetimeformatter/output/06-errors.txt | 11 ++ datetimeformatter/output/07-thread-safety.txt | 3 + datetimeformatter/output/08-tests.txt | 3 + datetimeformatter/pom.xml | 47 +++++ datetimeformatter/scripts/run-all.sh | 16 ++ datetimeformatter/scripts/run.sh | 6 + .../ankurm/datetimeformatter/ErrorsDemo.java | 36 ++++ .../datetimeformatter/IsoFormatsDemo.java | 37 ++++ .../ankurm/datetimeformatter/LocaleDemo.java | 96 +++++++++ .../ankurm/datetimeformatter/ParsingDemo.java | 76 ++++++++ .../datetimeformatter/PatternLettersDemo.java | 49 +++++ .../datetimeformatter/ThreadSafetyDemo.java | 74 +++++++ .../datetimeformatter/WeekYearBugDemo.java | 83 ++++++++ .../DateTimeFormatterTest.java | 182 ++++++++++++++++++ pom.xml | 1 + 22 files changed, 944 insertions(+) create mode 100644 datetimeformatter/README.md create mode 100644 datetimeformatter/output/01-pattern-letters.txt create mode 100644 datetimeformatter/output/02-week-year-bug.txt create mode 100644 datetimeformatter/output/03-locales.txt create mode 100644 datetimeformatter/output/04-parsing.txt create mode 100644 datetimeformatter/output/05-iso-formats.txt create mode 100644 datetimeformatter/output/06-errors.txt create mode 100644 datetimeformatter/output/07-thread-safety.txt create mode 100644 datetimeformatter/output/08-tests.txt create mode 100644 datetimeformatter/pom.xml create mode 100755 datetimeformatter/scripts/run-all.sh create mode 100755 datetimeformatter/scripts/run.sh create mode 100644 datetimeformatter/src/main/java/com/ankurm/datetimeformatter/ErrorsDemo.java create mode 100644 datetimeformatter/src/main/java/com/ankurm/datetimeformatter/IsoFormatsDemo.java create mode 100644 datetimeformatter/src/main/java/com/ankurm/datetimeformatter/LocaleDemo.java create mode 100644 datetimeformatter/src/main/java/com/ankurm/datetimeformatter/ParsingDemo.java create mode 100644 datetimeformatter/src/main/java/com/ankurm/datetimeformatter/PatternLettersDemo.java create mode 100644 datetimeformatter/src/main/java/com/ankurm/datetimeformatter/ThreadSafetyDemo.java create mode 100644 datetimeformatter/src/main/java/com/ankurm/datetimeformatter/WeekYearBugDemo.java create mode 100644 datetimeformatter/src/test/java/com/ankurm/datetimeformatter/DateTimeFormatterTest.java diff --git a/README.md b/README.md index 1de5063..db8905f 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ article; each module's own README has that article's version table, quickstart, | [`string-format`](string-format/) | String.format, formatted() and printf in Java: The Complete Cheat Sheet | | [`equals-hashcode`](equals-hashcode/) | equals() and hashCode() in Java: The Contract, Real Bugs and How Records Change It | | [`copying`](copying/) | Deep Copy vs Shallow Copy in Java: clone(), Copy Constructors, Serialization and Records | +| [`datetimeformatter`](datetimeformatter/) | DateTimeFormatter Cheat Sheet: Patterns, the yyyy vs YYYY Bug, Locales and ISO Formats | ## License diff --git a/datetimeformatter/README.md b/datetimeformatter/README.md new file mode 100644 index 0000000..3c3efdb --- /dev/null +++ b/datetimeformatter/README.md @@ -0,0 +1,44 @@ +# datetimeformatter + +Companion code for the ankurm.com post *"DateTimeFormatter Cheat Sheet: Patterns, the yyyy vs YYYY Bug, Locales and ISO Formats."* +Module in `java-core-examples`, the Java-core series. All explanation lives in the post; this module holds the code and the captured output. + +## Versions this was built and tested against + +| Component | Version | Notes | +|---|---|---| +| JDK | 25.0.5+8 (Temurin, LTS) | `java.time` arrived in Java 8. Locale data is CLDR; the narrow no-break space before AM/PM comes from the CLDR 42 update that shipped in JDK 20. | +| JUnit Jupiter | 5.11.0 | | +| Maven | 3.9.11 | | + +## Quickstart + +```bash +export JAVA_HOME=/path/to/jdk-25 +mvn compile +scripts/run.sh WeekYearBugDemo # one demo +scripts/run-all.sh # regenerate every file in output/ +``` + +## What's in here + +| File | What it shows | Output | +|---|---|---| +| `PatternLettersDemo.java` | About fifty pattern strings applied to one fixed moment: how repeat count changes `M`, `E`, `S`, `z`, `X`, `O`, and what `A`, `n`, `e`, `B`, `w`, `W` give. | `output/01` | +| `WeekYearBugDemo.java` | `yyyy` vs `YYYY` vs `uuuu`: the same date in four locales, and how many days in 2020-2030 each locale gets wrong. | `output/02` | +| `LocaleDemo.java` | en_IN, hi_IN, mr_IN; `Sept`; lowercase `pm`; the U+202F character before AM/PM the parse failure it causes, and `parseLenient()` as the fix. | `output/03` | +| `ParsingDemo.java` | `STRICT` / `SMART` / `LENIENT` on 31-02-2026, `yyyy` vs `uuuu` under STRICT, digit counts, two-digit years, case sensitivity, and `SimpleDateFormat` for comparison. | `output/04` | +| `IsoFormatsDemo.java` | Every predefined ISO constant on one value. | `output/05` | +| `ErrorsDemo.java` | The eleven exceptions people actually meet, with the real messages. | `output/06` | +| `ThreadSafetyDemo.java` | One shared `SimpleDateFormat` vs one shared `DateTimeFormatter`, 8 threads. Counts for `SimpleDateFormat` drift between runs. | `output/07` | +| `DateTimeFormatterTest.java` | Pins every claim above as an assertion, 18/18 passing. | `output/08` | + +## Notes worth knowing before reading the post + +- **`YYYY` is the week-based year.** On 2026-12-29 it prints `2027` in `en_US` and `en_IN` and `2026` in `en_GB` and `fr_FR` (`output/02`). +- **The default resolver style is `SMART`**, which parses `31-02-2026` as 28 February 2026 instead of failing (`output/04`). +- **`ofLocalizedTime(SHORT)` puts U+202F, not a space, before `PM`**, so a typed `3:30 PM` does not parse back (`output/03`). + +## License + +MIT - see the [repo-wide LICENSE](../LICENSE). diff --git a/datetimeformatter/output/01-pattern-letters.txt b/datetimeformatter/output/01-pattern-letters.txt new file mode 100644 index 0000000..2038cd3 --- /dev/null +++ b/datetimeformatter/output/01-pattern-letters.txt @@ -0,0 +1,61 @@ +moment = 2026-11-14T01:17:05.123456789+05:30[Asia/Kolkata] (a Saturday, day 318 of the year) + +yyyy -> 2026 +yy -> 26 +uuuu -> 2026 +M -> 11 +MM -> 11 +MMM -> Nov +MMMM -> November +MMMMM -> N +d -> 14 +dd -> 14 +D -> 318 +DDD -> 318 +E -> Sat +EEEE -> Saturday +EEEEE -> S +e -> 7 +ee -> 07 +H -> 1 +HH -> 01 +h -> 1 +hh -> 01 +k -> 1 +K -> 1 +a -> AM +B -> in the morning +BBBB -> in the morning +m -> 17 +mm -> 17 +s -> 5 +ss -> 05 +S -> 1 +SSS -> 123 +SSSSSS -> 123456 +n -> 123456789 +A -> 4625123 +G -> AD +Q -> 4 +QQQ -> Q4 +w -> 46 +W -> 2 +z -> IST +zzzz -> India Standard Time +VV -> Asia/Kolkata +O -> GMT+5:30 +OOOO -> GMT+05:30 +X -> +0530 +XXX -> +05:30 +x -> +0530 +xxx -> +05:30 +Z -> +0530 +ZZZZ -> GMT+05:30 +ZZZZZ -> +05:30 + +== the four hour letters, at midnight, 01:00, noon and 13:00 == +time H h k K +00:17 0 12 24 0 +01:17 1 1 1 1 +12:17 12 12 12 0 +13:17 13 1 13 1 diff --git a/datetimeformatter/output/02-week-year-bug.txt b/datetimeformatter/output/02-week-year-bug.txt new file mode 100644 index 0000000..a555096 --- /dev/null +++ b/datetimeformatter/output/02-week-year-bug.txt @@ -0,0 +1,35 @@ +== one date, two letters == +date = 2026-12-29 (TUESDAY) +dd-MM-yyyy = 29-12-2026 +dd-MM-YYYY = 29-12-2027 + +== the same YYYY pattern, four locales, three dates == +date en_US en_GB fr_FR en_IN +2026-12-28 2027 2026 2026 2027 +2026-12-29 2027 2026 2026 2027 +2027-01-01 2027 2026 2026 2027 + +== why: each locale defines what a week is (WeekFields) == +en_US WeekFields[SUNDAY,1] week-based-year of 2026-12-29 = 2027, week number = 1 +en_GB WeekFields[MONDAY,4] week-based-year of 2026-12-29 = 2026, week number = 53 +fr_FR WeekFields[MONDAY,4] week-based-year of 2026-12-29 = 2026, week number = 53 +en_IN WeekFields[SUNDAY,1] week-based-year of 2026-12-29 = 2027, week number = 1 + +== how often is YYYY wrong? every day of 2020-2030, compared with yyyy == +en_US 36 wrong days out of 4018 +en_GB 19 wrong days out of 4018 +fr_FR 19 wrong days out of 4018 +en_IN 36 wrong days out of 4018 + +== the days that are wrong in the US locale == +2024/2025: 2024-12-29->2025, 2024-12-30->2025, 2024-12-31->2025 +2025/2026: 2025-12-28->2026, 2025-12-29->2026, 2025-12-30->2026, 2025-12-31->2026 +2026/2027: 2026-12-27->2027, 2026-12-28->2027, 2026-12-29->2027, 2026-12-30->2027, 2026-12-31->2027 +2027/2028: 2027-12-26->2028, 2027-12-27->2028, 2027-12-28->2028, 2027-12-29->2028, 2027-12-30->2028, 2027-12-31->2028 +2028/2029: 2028-12-31->2029 + +== yyyy vs uuuu: same for modern dates, different for the era == +date = -0043-03-15 +yyyy = 0044 +uuuu = -0043 +yyyy G = 0044 BC diff --git a/datetimeformatter/output/03-locales.txt b/datetimeformatter/output/03-locales.txt new file mode 100644 index 0000000..c785d16 --- /dev/null +++ b/datetimeformatter/output/03-locales.txt @@ -0,0 +1,34 @@ +== the same pattern in four locales == +en_US Tuesday, 1 September 2026 +en_IN Tuesday, 1 September 2026 +hi_IN मंगलवार, 1 सितंबर 2026 +mr_IN मंगळवार, 1 सप्टेंबर 2026 + +== abbreviated month: not what every Indian or British reader expects == +en_US 1 Sep 2026 +en_GB 1 Sept 2026 +en_IN 1 Sept 2026 + +== localized styles for en_IN (the pattern is chosen by the locale, not by you) == +MEDIUM 1 Sept 2026 +SHORT 01/09/26 +FULL Tuesday, 1 September 2026 + +== AM/PM text per locale, with the hex of every character == +en_US [03:30 PM] 30 33 3A 33 30 20 50 4D +en_IN [03:30 pm] 30 33 3A 33 30 20 70 6D + +== ofLocalizedTime(SHORT): the space before PM is not a space == +en_US 33 3A 33 30 202F 50 4D +en_IN 33 3A 33 30 202F 70 6D + +== parsing it back with a plain space == +DateTimeParseException: Text '3:30 PM' could not be parsed at index 4 +15:30 + +== the other direction: text that CONTAINS U+202F, read by a pattern with a plain space == +DateTimeParseException: Text '3:30_PM' could not be parsed at index 4 + +== parseLenient() accepts either kind of space, in both directions == +localized SHORT, input with plain space : 15:30 +pattern h:mm a, input with U+202F : 15:30 diff --git a/datetimeformatter/output/04-parsing.txt b/datetimeformatter/output/04-parsing.txt new file mode 100644 index 0000000..34f78eb --- /dev/null +++ b/datetimeformatter/output/04-parsing.txt @@ -0,0 +1,30 @@ +== 31 February under three resolver styles (pattern dd-MM-uuuu) == +STRICT DateTimeParseException: Text '31-02-2026' could not be parsed: Invalid date 'FEBRUARY 31' +SMART 2026-02-28 +LENIENT 2026-03-03 + +== 30 February and 32 January == +SMART 30-02-2026 2026-02-28 +SMART 32-01-2026 DateTimeParseException: Text '32-01-2026' could not be parsed: Invalid value for DayOfMonth (valid values 1 - 28/31): 32 + +== STRICT with yyyy: year-of-era needs an era, so it cannot resolve == +yyyy DateTimeParseException: Text '14-11-2026' could not be parsed: Unable to obtain LocalDate from TemporalAccessor: {MonthOfYear=11, DayOfMonth=14, YearOfEra=2026},ISO of type java.time.format.Parsed +uuuu 2026-11-14 + +== how many digits? MM wants two, M accepts one or two == +yyyy-MM-dd 2026-1-5 DateTimeParseException: Text '2026-1-5' could not be parsed at index 5 +yyyy-M-d 2026-1-5 2026-01-05 +yyyy-M-d 2026-01-05 2026-01-05 + +== two-digit years are read as 2000-2099 == +01-01-30 -> 2030-01-01 +01-01-99 -> 2099-01-01 + +== month names are case-sensitive unless you say otherwise == +14 Nov 2026 2026-11-14 +14 NOV 2026 DateTimeParseException: Text '14 NOV 2026' could not be parsed at index 3 +14 NOV 2026 (parseCaseInsensitive) 2026-11-14 + +== the old SimpleDateFormat, same 31-02-2026 == +lenient (default): Tue Mar 03 00:00:00 IST 2026 +setLenient(false): ParseException: Unparseable date: "31-02-2026" diff --git a/datetimeformatter/output/05-iso-formats.txt b/datetimeformatter/output/05-iso-formats.txt new file mode 100644 index 0000000..c22ddeb --- /dev/null +++ b/datetimeformatter/output/05-iso-formats.txt @@ -0,0 +1,19 @@ +moment = 2026-12-29T01:17:05.123+05:30[Asia/Kolkata] + +ISO_LOCAL_DATE 2026-12-29 +ISO_LOCAL_TIME 01:17:05.123 +ISO_LOCAL_DATE_TIME 2026-12-29T01:17:05.123 +ISO_OFFSET_DATE_TIME 2026-12-29T01:17:05.123+05:30 +ISO_ZONED_DATE_TIME 2026-12-29T01:17:05.123+05:30[Asia/Kolkata] +ISO_INSTANT 2026-12-28T19:47:05.123Z +ISO_DATE_TIME 2026-12-29T01:17:05.123+05:30[Asia/Kolkata] +ISO_ORDINAL_DATE 2026-363+05:30 +ISO_WEEK_DATE 2026-W53-2+05:30 +BASIC_ISO_DATE 20261229+0530 +RFC_1123_DATE_TIME Tue, 29 Dec 2026 01:17:05 +0530 + +== ISO_INSTANT on a ZonedDateTime converts to UTC == +2026-12-28T19:47:05.123Z + +== toString() is already ISO for the java.time types == +2026-12-29 | 01:17:05.123 | 2026-12-29T01:17:05.123+05:30 diff --git a/datetimeformatter/output/06-errors.txt b/datetimeformatter/output/06-errors.txt new file mode 100644 index 0000000..6700e8d --- /dev/null +++ b/datetimeformatter/output/06-errors.txt @@ -0,0 +1,11 @@ +hour pattern on a LocalDate UnsupportedTemporalTypeException: Unsupported field: HourOfDay +zone pattern on a LocalDateTime DateTimeException: Unable to extract ZoneId from temporal 2026-11-14T01:17 +unknown pattern letter 'b' IllegalArgumentException: Unknown pattern letter: b +unterminated quote IllegalArgumentException: Pattern ends with an incomplete string literal: uuuu-MM-dd'T +the T in an ISO pattern, unquoted IllegalArgumentException: Unknown pattern letter: T +the T in an ISO pattern, quoted OK: 2026-11-14T01:17 +text too short DateTimeParseException: Text '2026-11' could not be parsed at index 7 +text too long DateTimeParseException: Text '2026-11-14 extra' could not be parsed, unparsed text found at index 10 +date-only text into a LocalDateTime DateTimeParseException: Text '2026-11-14' could not be parsed: Unable to obtain LocalDateTime from TemporalAccessor: {},ISO resolved to 2026-11-14 of type java.time.format.Parsed +month name in the wrong language DateTimeParseException: Text '14 novembre 2026' could not be parsed at index 3 +same text, French locale OK: 2026-11-14 diff --git a/datetimeformatter/output/07-thread-safety.txt b/datetimeformatter/output/07-thread-safety.txt new file mode 100644 index 0000000..53045f6 --- /dev/null +++ b/datetimeformatter/output/07-thread-safety.txt @@ -0,0 +1,3 @@ +threads=8, parses per thread=20000 +SimpleDateFormat (shared) : exceptions=461, wrong results=3824 +DateTimeFormatter (shared): exceptions=0, wrong results=0 diff --git a/datetimeformatter/output/08-tests.txt b/datetimeformatter/output/08-tests.txt new file mode 100644 index 0000000..bf4c064 --- /dev/null +++ b/datetimeformatter/output/08-tests.txt @@ -0,0 +1,3 @@ +[INFO] Tests run: 18, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.918 s -- in com.ankurm.datetimeformatter.DateTimeFormatterTest +[INFO] Tests run: 18, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS diff --git a/datetimeformatter/pom.xml b/datetimeformatter/pom.xml new file mode 100644 index 0000000..5378842 --- /dev/null +++ b/datetimeformatter/pom.xml @@ -0,0 +1,47 @@ + + + 4.0.0 + + + com.ankurm + java-core-examples + 1.0 + + + datetimeformatter + datetimeformatter + DateTimeFormatter cheat sheet: pattern letters, the yyyy vs YYYY week-year bug, locales (including en_IN and the narrow no-break space trap), parsing strictness, ISO constants, and thread safety against SimpleDateFormat. + + + + org.junit.jupiter + junit-jupiter + 5.11.0 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + 25 + -Xlint:-deprecation + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + -Dstdout.encoding=UTF-8 -Dfile.encoding=UTF-8 + + + + + diff --git a/datetimeformatter/scripts/run-all.sh b/datetimeformatter/scripts/run-all.sh new file mode 100755 index 0000000..ffed3fd --- /dev/null +++ b/datetimeformatter/scripts/run-all.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Regenerates every file in output/. 07 (threads) drifts between runs: the counts are indicative. +set -euo pipefail +cd "$(dirname "$0")/.." +mvn -q -B compile +mkdir -p output +run() { java -Dstdout.encoding=UTF-8 -cp target/classes "com.ankurm.datetimeformatter.$1" 2>&1 | grep -v '^Picked up JAVA_TOOL_OPTIONS' > "output/$2.txt"; } +run PatternLettersDemo 01-pattern-letters +run WeekYearBugDemo 02-week-year-bug +run LocaleDemo 03-locales +run ParsingDemo 04-parsing +run IsoFormatsDemo 05-iso-formats +run ErrorsDemo 06-errors +run ThreadSafetyDemo 07-thread-safety +mvn -B test 2>&1 | grep -E 'Tests run|BUILD|ERROR|FAIL' | grep -v 'Picked up' > output/08-tests.txt +echo "regenerated:"; ls output diff --git a/datetimeformatter/scripts/run.sh b/datetimeformatter/scripts/run.sh new file mode 100755 index 0000000..b77d94a --- /dev/null +++ b/datetimeformatter/scripts/run.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Run one demo: scripts/run.sh WeekYearBugDemo +set -euo pipefail +cd "$(dirname "$0")/.." +mvn -q -B compile +java -Dstdout.encoding=UTF-8 -cp target/classes "com.ankurm.datetimeformatter.${1:?demo class name}" diff --git a/datetimeformatter/src/main/java/com/ankurm/datetimeformatter/ErrorsDemo.java b/datetimeformatter/src/main/java/com/ankurm/datetimeformatter/ErrorsDemo.java new file mode 100644 index 0000000..d883b15 --- /dev/null +++ b/datetimeformatter/src/main/java/com/ankurm/datetimeformatter/ErrorsDemo.java @@ -0,0 +1,36 @@ +package com.ankurm.datetimeformatter; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.function.Supplier; + +/** The exceptions people actually meet, each with the real message. */ +public class ErrorsDemo { + + public static void main(String[] args) { + LocalDate date = LocalDate.of(2026, 11, 14); + LocalDateTime dateTime = LocalDateTime.of(2026, 11, 14, 1, 17); + + run("hour pattern on a LocalDate", () -> DateTimeFormatter.ofPattern("dd-MM-uuuu HH:mm").format(date)); + run("zone pattern on a LocalDateTime", () -> DateTimeFormatter.ofPattern("HH:mm z").format(dateTime)); + run("unknown pattern letter 'b'", () -> DateTimeFormatter.ofPattern("uuuu-MM-dd b")); + run("unterminated quote", () -> DateTimeFormatter.ofPattern("uuuu-MM-dd'T")); + run("the T in an ISO pattern, unquoted", () -> DateTimeFormatter.ofPattern("uuuu-MM-ddTHH:mm")); + run("the T in an ISO pattern, quoted", () -> DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm").format(dateTime)); + run("text too short", () -> LocalDate.parse("2026-11", DateTimeFormatter.ofPattern("uuuu-MM-dd"))); + run("text too long", () -> LocalDate.parse("2026-11-14 extra", DateTimeFormatter.ofPattern("uuuu-MM-dd"))); + run("date-only text into a LocalDateTime", () -> LocalDateTime.parse("2026-11-14", DateTimeFormatter.ofPattern("uuuu-MM-dd"))); + run("month name in the wrong language", () -> LocalDate.parse("14 novembre 2026", DateTimeFormatter.ofPattern("d MMMM uuuu", Locale.US))); + run("same text, French locale", () -> LocalDate.parse("14 novembre 2026", DateTimeFormatter.ofPattern("d MMMM uuuu", Locale.FRANCE))); + } + + static void run(String label, Supplier action) { + try { + System.out.printf("%-38s OK: %s%n", label, action.get()); + } catch (RuntimeException e) { + System.out.printf("%-38s %s: %s%n", label, e.getClass().getSimpleName(), e.getMessage()); + } + } +} diff --git a/datetimeformatter/src/main/java/com/ankurm/datetimeformatter/IsoFormatsDemo.java b/datetimeformatter/src/main/java/com/ankurm/datetimeformatter/IsoFormatsDemo.java new file mode 100644 index 0000000..6c26998 --- /dev/null +++ b/datetimeformatter/src/main/java/com/ankurm/datetimeformatter/IsoFormatsDemo.java @@ -0,0 +1,37 @@ +package com.ankurm.datetimeformatter; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; + +/** The predefined constants. Reach for these before writing an ofPattern string for a machine. */ +public class IsoFormatsDemo { + + public static void main(String[] args) { + ZonedDateTime z = ZonedDateTime.of(2026, 12, 29, 1, 17, 5, 123_000_000, ZoneId.of("Asia/Kolkata")); + System.out.println("moment = " + z); + System.out.println(); + show("ISO_LOCAL_DATE", DateTimeFormatter.ISO_LOCAL_DATE, z); + show("ISO_LOCAL_TIME", DateTimeFormatter.ISO_LOCAL_TIME, z); + show("ISO_LOCAL_DATE_TIME", DateTimeFormatter.ISO_LOCAL_DATE_TIME, z); + show("ISO_OFFSET_DATE_TIME", DateTimeFormatter.ISO_OFFSET_DATE_TIME, z); + show("ISO_ZONED_DATE_TIME", DateTimeFormatter.ISO_ZONED_DATE_TIME, z); + show("ISO_INSTANT", DateTimeFormatter.ISO_INSTANT, z); + show("ISO_DATE_TIME", DateTimeFormatter.ISO_DATE_TIME, z); + show("ISO_ORDINAL_DATE", DateTimeFormatter.ISO_ORDINAL_DATE, z); + show("ISO_WEEK_DATE", DateTimeFormatter.ISO_WEEK_DATE, z); + show("BASIC_ISO_DATE", DateTimeFormatter.BASIC_ISO_DATE, z); + show("RFC_1123_DATE_TIME", DateTimeFormatter.RFC_1123_DATE_TIME, z); + + System.out.println(); + System.out.println("== ISO_INSTANT on a ZonedDateTime converts to UTC =="); + System.out.println(DateTimeFormatter.ISO_INSTANT.format(z)); + System.out.println(); + System.out.println("== toString() is already ISO for the java.time types =="); + System.out.println(z.toLocalDate() + " | " + z.toLocalTime() + " | " + z.toOffsetDateTime()); + } + + static void show(String name, DateTimeFormatter f, ZonedDateTime z) { + System.out.printf("%-22s %s%n", name, f.format(z)); + } +} diff --git a/datetimeformatter/src/main/java/com/ankurm/datetimeformatter/LocaleDemo.java b/datetimeformatter/src/main/java/com/ankurm/datetimeformatter/LocaleDemo.java new file mode 100644 index 0000000..6d57ab0 --- /dev/null +++ b/datetimeformatter/src/main/java/com/ankurm/datetimeformatter/LocaleDemo.java @@ -0,0 +1,96 @@ +package com.ankurm.datetimeformatter; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.format.FormatStyle; +import java.util.Locale; + +/** + * What locale changes: month and day names, localized styles, AM/PM text, and the character between + * the time and the AM/PM marker, which has been U+202F (narrow no-break space) rather than a plain + * space since the CLDR 42 update that arrived in JDK 20. + */ +public class LocaleDemo { + + static final Locale EN_IN = Locale.of("en", "IN"); + static final Locale HI_IN = Locale.of("hi", "IN"); + static final Locale MR_IN = Locale.of("mr", "IN"); + + public static void main(String[] args) { + LocalDate d = LocalDate.of(2026, 9, 1); + LocalTime t = LocalTime.of(15, 30); + + System.out.println("== the same pattern in four locales =="); + DateTimeFormatter names = DateTimeFormatter.ofPattern("EEEE, d MMMM uuuu"); + for (Locale l : new Locale[]{Locale.US, EN_IN, HI_IN, MR_IN}) { + System.out.printf("%-6s %s%n", l, names.withLocale(l).format(d)); + } + + System.out.println(); + System.out.println("== abbreviated month: not what every Indian or British reader expects =="); + DateTimeFormatter abbr = DateTimeFormatter.ofPattern("d MMM uuuu"); + for (Locale l : new Locale[]{Locale.US, Locale.UK, EN_IN}) { + System.out.printf("%-6s %s%n", l, abbr.withLocale(l).format(d)); + } + + System.out.println(); + System.out.println("== localized styles for en_IN (the pattern is chosen by the locale, not by you) =="); + for (FormatStyle s : FormatStyle.values()) { + if (s == FormatStyle.FULL || s == FormatStyle.LONG) continue; + System.out.printf("%-7s %s%n", s, DateTimeFormatter.ofLocalizedDate(s).withLocale(EN_IN).format(d)); + } + System.out.printf("FULL %s%n", DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(EN_IN).format(d)); + + System.out.println(); + System.out.println("== AM/PM text per locale, with the hex of every character =="); + String[] pats = {"hh:mm a"}; + for (Locale l : new Locale[]{Locale.US, EN_IN}) { + String s = DateTimeFormatter.ofPattern(pats[0]).withLocale(l).format(t); + System.out.printf("%-6s [%s] %s%n", l, s, hex(s)); + } + + System.out.println(); + System.out.println("== ofLocalizedTime(SHORT): the space before PM is not a space =="); + for (Locale l : new Locale[]{Locale.US, EN_IN}) { + String s = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(l).format(t); + System.out.printf("%-6s %s%n", l, hex(s)); + } + + System.out.println(); + System.out.println("== parsing it back with a plain space =="); + String typed = "3:30 PM"; + try { + System.out.println(LocalTime.parse(typed, DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(Locale.US))); + } catch (RuntimeException e) { + System.out.println(e.getClass().getSimpleName() + ": " + e.getMessage()); + } + System.out.println(LocalTime.parse(typed, DateTimeFormatter.ofPattern("h:mm a", Locale.US))); + + System.out.println(); + System.out.println("== the other direction: text that CONTAINS U+202F, read by a pattern with a plain space =="); + String withNnbsp = "3:30\u202FPM"; + DateTimeFormatter plainPattern = DateTimeFormatter.ofPattern("h:mm a", Locale.US); + try { + System.out.println(LocalTime.parse(withNnbsp, plainPattern)); + } catch (RuntimeException e) { + System.out.println(e.getClass().getSimpleName() + ": " + e.getMessage().replace('\u202F', '_')); + } + + System.out.println(); + System.out.println("== parseLenient() accepts either kind of space, in both directions =="); + DateTimeFormatter lenientShort = new DateTimeFormatterBuilder() + .parseLenient().appendLocalized(null, FormatStyle.SHORT).toFormatter(Locale.US); + DateTimeFormatter lenientPattern = new DateTimeFormatterBuilder() + .parseLenient().appendPattern("h:mm a").toFormatter(Locale.US); + System.out.println("localized SHORT, input with plain space : " + LocalTime.parse(typed, lenientShort)); + System.out.println("pattern h:mm a, input with U+202F : " + LocalTime.parse(withNnbsp, lenientPattern)); + } + + static String hex(String s) { + StringBuilder sb = new StringBuilder(); + s.chars().forEach(c -> sb.append(String.format("%02X ", c))); + return sb.toString().trim(); + } +} diff --git a/datetimeformatter/src/main/java/com/ankurm/datetimeformatter/ParsingDemo.java b/datetimeformatter/src/main/java/com/ankurm/datetimeformatter/ParsingDemo.java new file mode 100644 index 0000000..11c7cf9 --- /dev/null +++ b/datetimeformatter/src/main/java/com/ankurm/datetimeformatter/ParsingDemo.java @@ -0,0 +1,76 @@ +package com.ankurm.datetimeformatter; + +import java.text.SimpleDateFormat; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.format.ResolverStyle; +import java.util.Locale; + +/** + * Parsing is where DateTimeFormatter is least forgiving AND where its defaults are most generous. + * The default resolver style, SMART, accepts 31-02-2026 and quietly returns 28 February. + */ +public class ParsingDemo { + + public static void main(String[] args) { + System.out.println("== 31 February under three resolver styles (pattern dd-MM-uuuu) =="); + for (ResolverStyle style : ResolverStyle.values()) { + DateTimeFormatter f = DateTimeFormatter.ofPattern("dd-MM-uuuu").withResolverStyle(style); + System.out.printf("%-8s %s%n", style, attempt("31-02-2026", f)); + } + + System.out.println(); + System.out.println("== 30 February and 32 January =="); + DateTimeFormatter smart = DateTimeFormatter.ofPattern("dd-MM-uuuu"); + System.out.printf("SMART 30-02-2026 %s%n", attempt("30-02-2026", smart)); + System.out.printf("SMART 32-01-2026 %s%n", attempt("32-01-2026", smart)); + + System.out.println(); + System.out.println("== STRICT with yyyy: year-of-era needs an era, so it cannot resolve =="); + DateTimeFormatter strictY = DateTimeFormatter.ofPattern("dd-MM-yyyy").withResolverStyle(ResolverStyle.STRICT); + DateTimeFormatter strictU = DateTimeFormatter.ofPattern("dd-MM-uuuu").withResolverStyle(ResolverStyle.STRICT); + System.out.printf("yyyy %s%n", attempt("14-11-2026", strictY)); + System.out.printf("uuuu %s%n", attempt("14-11-2026", strictU)); + + System.out.println(); + System.out.println("== how many digits? MM wants two, M accepts one or two =="); + System.out.printf("yyyy-MM-dd 2026-1-5 %s%n", attempt("2026-1-5", DateTimeFormatter.ofPattern("yyyy-MM-dd"))); + System.out.printf("yyyy-M-d 2026-1-5 %s%n", attempt("2026-1-5", DateTimeFormatter.ofPattern("yyyy-M-d"))); + System.out.printf("yyyy-M-d 2026-01-05 %s%n", attempt("2026-01-05", DateTimeFormatter.ofPattern("yyyy-M-d"))); + + System.out.println(); + System.out.println("== two-digit years are read as 2000-2099 =="); + DateTimeFormatter yy = DateTimeFormatter.ofPattern("dd-MM-yy"); + System.out.printf("01-01-30 -> %s%n", attempt("01-01-30", yy)); + System.out.printf("01-01-99 -> %s%n", attempt("01-01-99", yy)); + + System.out.println(); + System.out.println("== month names are case-sensitive unless you say otherwise =="); + DateTimeFormatter mmm = DateTimeFormatter.ofPattern("dd MMM uuuu", Locale.US); + System.out.printf("14 Nov 2026 %s%n", attempt("14 Nov 2026", mmm)); + System.out.printf("14 NOV 2026 %s%n", attempt("14 NOV 2026", mmm)); + DateTimeFormatter insensitive = new java.time.format.DateTimeFormatterBuilder() + .parseCaseInsensitive().appendPattern("dd MMM uuuu").toFormatter(Locale.US); + System.out.printf("14 NOV 2026 (parseCaseInsensitive) %s%n", attempt("14 NOV 2026", insensitive)); + + System.out.println(); + System.out.println("== the old SimpleDateFormat, same 31-02-2026 =="); + try { + System.out.println("lenient (default): " + new SimpleDateFormat("dd-MM-yyyy").parse("31-02-2026")); + SimpleDateFormat strict = new SimpleDateFormat("dd-MM-yyyy"); + strict.setLenient(false); + System.out.println("setLenient(false): " + strict.parse("31-02-2026")); + } catch (java.text.ParseException e) { + System.out.println("setLenient(false): ParseException: " + e.getMessage()); + } + } + + static String attempt(String text, DateTimeFormatter f) { + try { + return LocalDate.parse(text, f).toString(); + } catch (DateTimeParseException e) { + return e.getClass().getSimpleName() + ": " + e.getMessage(); + } + } +} diff --git a/datetimeformatter/src/main/java/com/ankurm/datetimeformatter/PatternLettersDemo.java b/datetimeformatter/src/main/java/com/ankurm/datetimeformatter/PatternLettersDemo.java new file mode 100644 index 0000000..a2bf5b1 --- /dev/null +++ b/datetimeformatter/src/main/java/com/ankurm/datetimeformatter/PatternLettersDemo.java @@ -0,0 +1,49 @@ +package com.ankurm.datetimeformatter; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Locale; + +/** + * Every pattern letter you are likely to need, applied to ONE fixed moment so the table in the post + * is a real run, not a remembered one. Repeat count changes the output: M, MM, MMM, MMMM, MMMMM. + */ +public class PatternLettersDemo { + + static final ZonedDateTime MOMENT = + ZonedDateTime.of(2026, 11, 14, 1, 17, 5, 123_456_789, ZoneId.of("Asia/Kolkata")); + + public static void main(String[] args) { + System.out.println("moment = " + MOMENT + " (a Saturday, day 318 of the year)"); + System.out.println(); + String[] patterns = { + "yyyy", "yy", "uuuu", + "M", "MM", "MMM", "MMMM", "MMMMM", + "d", "dd", "D", "DDD", + "E", "EEEE", "EEEEE", "e", "ee", + "H", "HH", "h", "hh", "k", "K", + "a", "B", "BBBB", + "m", "mm", "s", "ss", + "S", "SSS", "SSSSSS", "n", "A", + "G", "Q", "QQQ", "w", "W", + "z", "zzzz", "VV", "O", "OOOO", "X", "XXX", "x", "xxx", "Z", "ZZZZ", "ZZZZZ" + }; + for (String p : patterns) { + System.out.printf("%-7s -> %s%n", p, MOMENT.format(DateTimeFormatter.ofPattern(p, Locale.US))); + } + + System.out.println(); + System.out.println("== the four hour letters, at midnight, 01:00, noon and 13:00 =="); + System.out.printf("%-6s %-3s %-3s %-3s %-3s%n", "time", "H", "h", "k", "K"); + int[] hours = {0, 1, 12, 13}; + for (int hour : hours) { + ZonedDateTime t = MOMENT.withHour(hour); + System.out.printf("%-6s %-3s %-3s %-3s %-3s%n", String.format("%02d:17", hour), + t.format(DateTimeFormatter.ofPattern("H", Locale.US)), + t.format(DateTimeFormatter.ofPattern("h", Locale.US)), + t.format(DateTimeFormatter.ofPattern("k", Locale.US)), + t.format(DateTimeFormatter.ofPattern("K", Locale.US))); + } + } +} diff --git a/datetimeformatter/src/main/java/com/ankurm/datetimeformatter/ThreadSafetyDemo.java b/datetimeformatter/src/main/java/com/ankurm/datetimeformatter/ThreadSafetyDemo.java new file mode 100644 index 0000000..0d8a1e5 --- /dev/null +++ b/datetimeformatter/src/main/java/com/ankurm/datetimeformatter/ThreadSafetyDemo.java @@ -0,0 +1,74 @@ +package com.ankurm.datetimeformatter; + +import java.text.SimpleDateFormat; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Date; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * One shared SimpleDateFormat against one shared DateTimeFormatter, 8 threads, 20,000 parses each. + * SimpleDateFormat keeps its working state in the instance, so concurrent callers overwrite each + * other's work. The counts below drift between runs; DateTimeFormatter's count does not. + */ +public class ThreadSafetyDemo { + + static final int THREADS = 8; + static final int ITERATIONS = 20_000; + + public static void main(String[] args) throws Exception { + System.out.println("threads=" + THREADS + ", parses per thread=" + ITERATIONS); + Result old = runSimpleDateFormat(); + System.out.println("SimpleDateFormat (shared) : exceptions=" + old.exceptions + ", wrong results=" + old.wrong); + Result modern = runDateTimeFormatter(); + System.out.println("DateTimeFormatter (shared): exceptions=" + modern.exceptions + ", wrong results=" + modern.wrong); + } + + record Result(int exceptions, int wrong) {} + + static Result runSimpleDateFormat() throws Exception { + SimpleDateFormat shared = new SimpleDateFormat("dd-MM-yyyy"); + return hammer(i -> { + int day = 1 + (i % 28); + String text = String.format("%02d-11-2026", day); + Date parsed = shared.parse(text); + return new java.text.SimpleDateFormat("dd").format(parsed).equals(String.format("%02d", day)); + }); + } + + static Result runDateTimeFormatter() throws Exception { + DateTimeFormatter shared = DateTimeFormatter.ofPattern("dd-MM-uuuu"); + return hammer(i -> { + int day = 1 + (i % 28); + String text = String.format("%02d-11-2026", day); + LocalDate parsed = LocalDate.parse(text, shared); + return parsed.getDayOfMonth() == day; + }); + } + + interface Check { boolean ok(int i) throws Exception; } + + static Result hammer(Check check) throws Exception { + AtomicInteger exceptions = new AtomicInteger(); + AtomicInteger wrong = new AtomicInteger(); + CountDownLatch start = new CountDownLatch(1); + Thread[] threads = new Thread[THREADS]; + for (int t = 0; t < THREADS; t++) { + threads[t] = new Thread(() -> { + try { start.await(); } catch (InterruptedException e) { return; } + for (int i = 0; i < ITERATIONS; i++) { + try { + if (!check.ok(i)) wrong.incrementAndGet(); + } catch (Exception e) { + exceptions.incrementAndGet(); + } + } + }); + threads[t].start(); + } + start.countDown(); + for (Thread t : threads) t.join(); + return new Result(exceptions.get(), wrong.get()); + } +} diff --git a/datetimeformatter/src/main/java/com/ankurm/datetimeformatter/WeekYearBugDemo.java b/datetimeformatter/src/main/java/com/ankurm/datetimeformatter/WeekYearBugDemo.java new file mode 100644 index 0000000..4603626 --- /dev/null +++ b/datetimeformatter/src/main/java/com/ankurm/datetimeformatter/WeekYearBugDemo.java @@ -0,0 +1,83 @@ +package com.ankurm.datetimeformatter; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.temporal.WeekFields; +import java.util.Locale; + +/** + * yyyy is the year; YYYY is the WEEK-based year. They agree on 360-ish days a year and disagree on + * the few days around New Year, which is exactly why the bug survives testing and ships. + */ +public class WeekYearBugDemo { + + static final Locale EN_IN = Locale.of("en", "IN"); + static final LocalDate DEC_29 = LocalDate.of(2026, 12, 29); + + public static void main(String[] args) { + System.out.println("== one date, two letters =="); + LocalDate d = LocalDate.of(2026, 12, 29); + System.out.println("date = " + d + " (" + d.getDayOfWeek() + ")"); + System.out.println("dd-MM-yyyy = " + DateTimeFormatter.ofPattern("dd-MM-yyyy").format(d)); + System.out.println("dd-MM-YYYY = " + DateTimeFormatter.ofPattern("dd-MM-YYYY").format(d)); + + System.out.println(); + System.out.println("== the same YYYY pattern, four locales, three dates =="); + LocalDate[] dates = {LocalDate.of(2026, 12, 28), LocalDate.of(2026, 12, 29), LocalDate.of(2027, 1, 1)}; + Locale[] locales = {Locale.US, Locale.UK, Locale.FRANCE, EN_IN}; + System.out.printf("%-12s", "date"); + for (Locale l : locales) System.out.printf("%-10s", l); + System.out.println(); + for (LocalDate x : dates) { + System.out.printf("%-12s", x); + for (Locale l : locales) { + System.out.printf("%-10s", DateTimeFormatter.ofPattern("YYYY", l).format(x)); + } + System.out.println(); + } + + System.out.println(); + System.out.println("== why: each locale defines what a week is (WeekFields) =="); + for (Locale l : locales) { + WeekFields wf = WeekFields.of(l); + System.out.printf("%-6s %-22s week-based-year of 2026-12-29 = %d, week number = %d%n", l, wf, + DEC_29.get(wf.weekBasedYear()), DEC_29.get(wf.weekOfWeekBasedYear())); + } + + System.out.println(); + System.out.println("== how often is YYYY wrong? every day of 2020-2030, compared with yyyy =="); + for (Locale l : locales) { + int wrong = 0; + int total = 0; + DateTimeFormatter plain = DateTimeFormatter.ofPattern("yyyy", l); + DateTimeFormatter week = DateTimeFormatter.ofPattern("YYYY", l); + for (LocalDate x = LocalDate.of(2020, 1, 1); x.isBefore(LocalDate.of(2031, 1, 1)); x = x.plusDays(1)) { + total++; + if (!plain.format(x).equals(week.format(x))) wrong++; + } + System.out.printf("%-6s %3d wrong days out of %d%n", l, wrong, total); + } + + System.out.println(); + System.out.println("== the days that are wrong in the US locale =="); + DateTimeFormatter plain = DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.US); + DateTimeFormatter week = DateTimeFormatter.ofPattern("YYYY-MM-dd", Locale.US); + for (int year = 2024; year <= 2028; year++) { + StringBuilder sb = new StringBuilder(); + for (LocalDate x = LocalDate.of(year, 12, 20); !x.isAfter(LocalDate.of(year + 1, 1, 10)); x = x.plusDays(1)) { + if (!plain.format(x).equals(week.format(x))) { + sb.append(sb.isEmpty() ? "" : ", ").append(plain.format(x)).append("->").append(week.format(x), 0, 4); + } + } + System.out.println(year + "/" + (year + 1) + ": " + (sb.isEmpty() ? "none" : sb)); + } + + System.out.println(); + System.out.println("== yyyy vs uuuu: same for modern dates, different for the era =="); + LocalDate bc = LocalDate.of(-43, 3, 15); + System.out.println("date = " + bc); + System.out.println("yyyy = " + DateTimeFormatter.ofPattern("yyyy", Locale.US).format(bc)); + System.out.println("uuuu = " + DateTimeFormatter.ofPattern("uuuu", Locale.US).format(bc)); + System.out.println("yyyy G = " + DateTimeFormatter.ofPattern("yyyy G", Locale.US).format(bc)); + } +} diff --git a/datetimeformatter/src/test/java/com/ankurm/datetimeformatter/DateTimeFormatterTest.java b/datetimeformatter/src/test/java/com/ankurm/datetimeformatter/DateTimeFormatterTest.java new file mode 100644 index 0000000..65428c3 --- /dev/null +++ b/datetimeformatter/src/test/java/com/ankurm/datetimeformatter/DateTimeFormatterTest.java @@ -0,0 +1,182 @@ +package com.ankurm.datetimeformatter; + +import static org.junit.jupiter.api.Assertions.*; + +import java.text.SimpleDateFormat; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.format.FormatStyle; +import java.time.format.ResolverStyle; +import java.time.temporal.UnsupportedTemporalTypeException; +import java.util.Locale; +import org.junit.jupiter.api.Test; + +/** Pins every claim the post makes. If a JDK update changes one, the build fails instead of the post lying. */ +class DateTimeFormatterTest { + + static final Locale EN_IN = Locale.of("en", "IN"); + static final LocalDate DEC_29_2026 = LocalDate.of(2026, 12, 29); + + @Test + void weekYearDiffersFromYearAtNewYear() { + assertEquals("29-12-2026", DateTimeFormatter.ofPattern("dd-MM-yyyy").format(DEC_29_2026)); + assertEquals("29-12-2027", DateTimeFormatter.ofPattern("dd-MM-YYYY", Locale.US).format(DEC_29_2026)); + } + + @Test + void weekYearDependsOnLocale() { + assertEquals("2027", DateTimeFormatter.ofPattern("YYYY", Locale.US).format(DEC_29_2026)); + assertEquals("2027", DateTimeFormatter.ofPattern("YYYY", EN_IN).format(DEC_29_2026)); + assertEquals("2026", DateTimeFormatter.ofPattern("YYYY", Locale.UK).format(DEC_29_2026)); + assertEquals("2026", DateTimeFormatter.ofPattern("YYYY", Locale.FRANCE).format(DEC_29_2026)); + } + + @Test + void weekDefinitionExplainsTheLocaleDifference() { + assertEquals("WeekFields[SUNDAY,1]", java.time.temporal.WeekFields.of(Locale.US).toString()); + assertEquals("WeekFields[SUNDAY,1]", java.time.temporal.WeekFields.of(EN_IN).toString()); + assertEquals("WeekFields[MONDAY,4]", java.time.temporal.WeekFields.of(Locale.UK).toString()); + assertEquals("WeekFields[MONDAY,4]", java.time.temporal.WeekFields.of(Locale.FRANCE).toString()); + } + + @Test + void thirtySixWrongDaysInTenYearsForUsAndIndia() { + assertEquals(36, wrongDays(Locale.US)); + assertEquals(36, wrongDays(EN_IN)); + assertEquals(19, wrongDays(Locale.UK)); + } + + private static int wrongDays(Locale l) { + DateTimeFormatter plain = DateTimeFormatter.ofPattern("yyyy", l); + DateTimeFormatter week = DateTimeFormatter.ofPattern("YYYY", l); + int wrong = 0; + for (LocalDate x = LocalDate.of(2020, 1, 1); x.isBefore(LocalDate.of(2031, 1, 1)); x = x.plusDays(1)) { + if (!plain.format(x).equals(week.format(x))) wrong++; + } + return wrong; + } + + @Test + void yyyyIsYearOfEraSoBcDatesPrintPositive() { + LocalDate bc = LocalDate.of(-43, 3, 15); + assertEquals("0044", DateTimeFormatter.ofPattern("yyyy", Locale.US).format(bc)); + assertEquals("-0043", DateTimeFormatter.ofPattern("uuuu", Locale.US).format(bc)); + } + + @Test + void repeatCountChangesMonthText() { + ZonedDateTime z = PatternLettersDemo.MOMENT; + assertEquals("11", z.format(DateTimeFormatter.ofPattern("M", Locale.US))); + assertEquals("Nov", z.format(DateTimeFormatter.ofPattern("MMM", Locale.US))); + assertEquals("November", z.format(DateTimeFormatter.ofPattern("MMMM", Locale.US))); + assertEquals("N", z.format(DateTimeFormatter.ofPattern("MMMMM", Locale.US))); + } + + @Test + void hourLettersDisagreeAtMidnightAndNoon() { + ZonedDateTime midnight = PatternLettersDemo.MOMENT.withHour(0); + ZonedDateTime noon = PatternLettersDemo.MOMENT.withHour(12); + assertEquals("0", midnight.format(DateTimeFormatter.ofPattern("H", Locale.US))); + assertEquals("12", midnight.format(DateTimeFormatter.ofPattern("h", Locale.US))); + assertEquals("24", midnight.format(DateTimeFormatter.ofPattern("k", Locale.US))); + assertEquals("0", midnight.format(DateTimeFormatter.ofPattern("K", Locale.US))); + assertEquals("12", noon.format(DateTimeFormatter.ofPattern("H", Locale.US))); + assertEquals("0", noon.format(DateTimeFormatter.ofPattern("K", Locale.US))); + } + + @Test + void fractionAndMilliOfDayLetters() { + ZonedDateTime z = PatternLettersDemo.MOMENT; + assertEquals("123", z.format(DateTimeFormatter.ofPattern("SSS", Locale.US))); + assertEquals("123456789", z.format(DateTimeFormatter.ofPattern("n", Locale.US))); + assertEquals("4625123", z.format(DateTimeFormatter.ofPattern("A", Locale.US))); + } + + @Test + void localizedTimeUsesNarrowNoBreakSpaceBeforeAmPm() { + LocalTime t = LocalTime.of(15, 30); + String us = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(Locale.US).format(t); + assertEquals("3:30 PM", us); + assertThrows(DateTimeParseException.class, + () -> LocalTime.parse("3:30 PM", DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(Locale.US))); + } + + @Test + void parseLenientAcceptsEitherSpaceBeforeAmPm() { + DateTimeFormatter strictPattern = DateTimeFormatter.ofPattern("h:mm a", Locale.US); + assertThrows(DateTimeParseException.class, () -> LocalTime.parse("3:30\u202FPM", strictPattern)); + DateTimeFormatter lenientShort = new java.time.format.DateTimeFormatterBuilder() + .parseLenient().appendLocalized(null, FormatStyle.SHORT).toFormatter(Locale.US); + DateTimeFormatter lenientPattern = new java.time.format.DateTimeFormatterBuilder() + .parseLenient().appendPattern("h:mm a").toFormatter(Locale.US); + assertEquals(LocalTime.of(15, 30), LocalTime.parse("3:30 PM", lenientShort)); + assertEquals(LocalTime.of(15, 30), LocalTime.parse("3:30\u202FPM", lenientPattern)); + } + + @Test + void indianEnglishUsesLowercaseAmPmAndSept() { + assertEquals("03:30 pm", DateTimeFormatter.ofPattern("hh:mm a", EN_IN).format(LocalTime.of(15, 30))); + assertEquals("1 Sept 2026", DateTimeFormatter.ofPattern("d MMM uuuu", EN_IN).format(LocalDate.of(2026, 9, 1))); + assertEquals("1 Sep 2026", DateTimeFormatter.ofPattern("d MMM uuuu", Locale.US).format(LocalDate.of(2026, 9, 1))); + } + + @Test + void smartAcceptsThirtyFirstOfFebruaryAndStrictRejectsIt() { + DateTimeFormatter f = DateTimeFormatter.ofPattern("dd-MM-uuuu"); + assertEquals(LocalDate.of(2026, 2, 28), LocalDate.parse("31-02-2026", f)); + assertEquals(LocalDate.of(2026, 3, 3), LocalDate.parse("31-02-2026", f.withResolverStyle(ResolverStyle.LENIENT))); + assertThrows(DateTimeParseException.class, + () -> LocalDate.parse("31-02-2026", f.withResolverStyle(ResolverStyle.STRICT))); + } + + @Test + void strictWithYyyyCannotResolveButUuuuCan() { + DateTimeFormatter y = DateTimeFormatter.ofPattern("dd-MM-yyyy").withResolverStyle(ResolverStyle.STRICT); + DateTimeFormatter u = DateTimeFormatter.ofPattern("dd-MM-uuuu").withResolverStyle(ResolverStyle.STRICT); + assertThrows(DateTimeParseException.class, () -> LocalDate.parse("14-11-2026", y)); + assertEquals(LocalDate.of(2026, 11, 14), LocalDate.parse("14-11-2026", u)); + } + + @Test + void digitCountAndTwoDigitYear() { + assertThrows(DateTimeParseException.class, + () -> LocalDate.parse("2026-1-5", DateTimeFormatter.ofPattern("yyyy-MM-dd"))); + assertEquals(LocalDate.of(2026, 1, 5), LocalDate.parse("2026-1-5", DateTimeFormatter.ofPattern("yyyy-M-d"))); + assertEquals(LocalDate.of(2099, 1, 1), LocalDate.parse("01-01-99", DateTimeFormatter.ofPattern("dd-MM-yy"))); + } + + @Test + void errorsHaveTheDocumentedTypes() { + assertThrows(UnsupportedTemporalTypeException.class, + () -> DateTimeFormatter.ofPattern("HH:mm").format(DEC_29_2026)); + assertThrows(IllegalArgumentException.class, () -> DateTimeFormatter.ofPattern("uuuu-MM-ddTHH:mm")); + assertThrows(IllegalArgumentException.class, () -> DateTimeFormatter.ofPattern("uuuu-MM-dd'T")); + } + + @Test + void isoConstantsAndInstantConversion() { + ZonedDateTime z = ZonedDateTime.of(2026, 12, 29, 1, 17, 5, 123_000_000, ZoneId.of("Asia/Kolkata")); + assertEquals("2026-12-29T01:17:05.123+05:30", DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(z)); + assertEquals("2026-12-28T19:47:05.123Z", DateTimeFormatter.ISO_INSTANT.format(z)); + assertEquals("2026-W53-2+05:30", DateTimeFormatter.ISO_WEEK_DATE.format(z)); + } + + @Test + void simpleDateFormatIsLenientByDefault() throws Exception { + assertNotNull(new SimpleDateFormat("dd-MM-yyyy").parse("31-02-2026")); + SimpleDateFormat strict = new SimpleDateFormat("dd-MM-yyyy"); + strict.setLenient(false); + assertThrows(java.text.ParseException.class, () -> strict.parse("31-02-2026")); + } + + @Test + void sharedDateTimeFormatterIsThreadSafe() throws Exception { + ThreadSafetyDemo.Result r = ThreadSafetyDemo.runDateTimeFormatter(); + assertEquals(0, r.exceptions()); + assertEquals(0, r.wrong()); + } +} diff --git a/pom.xml b/pom.xml index 7f2861c..0d11739 100644 --- a/pom.xml +++ b/pom.xml @@ -39,6 +39,7 @@ string-format equals-hashcode copying + datetimeformatter