From a313efec49cec5553f23b5a50bd5d7f2bcdfe6e3 Mon Sep 17 00:00:00 2001 From: Torben Dannhauer Date: Fri, 22 May 2026 12:09:53 +0200 Subject: [PATCH 1/3] Enhance isPhpDateFormat to exclude ICU shortcuts Add check for ICU locale shortcuts in isPhpDateFormat method. --- src/Format.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Format.php b/src/Format.php index b1bda62..a254372 100644 --- a/src/Format.php +++ b/src/Format.php @@ -400,6 +400,11 @@ public static function parseDateTime( */ public static function isPhpDateFormat(string $pattern): bool { + // ICU locale shortcuts (handled by IcuFormatter, not PHP date()) + if (in_array($pattern, ['short', 'medium', 'long', 'full'], true)) { + return false; + } + // These characters are unique to PHP date() and don't appear as single // letters in ICU patterns in the same way $phpOnlyChars = ['i', 'j', 'n', 'g', 'A', 'N', 'L', 'o', 'U', 'u']; From 5266afa193e8b2f0df11d371e80ac18e3f0c8080 Mon Sep 17 00:00:00 2001 From: Torben Dannhauer Date: Fri, 22 May 2026 12:10:42 +0200 Subject: [PATCH 2/3] Update FormatParseTest.php --- test/Unit/FormatParseTest.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/Unit/FormatParseTest.php b/test/Unit/FormatParseTest.php index 9f4175b..615d716 100644 --- a/test/Unit/FormatParseTest.php +++ b/test/Unit/FormatParseTest.php @@ -183,6 +183,25 @@ public function testIsPhpDateFormatRejectsIcuPatterns(): void $this->assertFalse(Format::isPhpDateFormat('EEEE, MMMM dd')); } + public function testIsPhpDateFormatRejectsIcuShortcuts(): void + { + $this->assertFalse(Format::isPhpDateFormat('short')); + $this->assertFalse(Format::isPhpDateFormat('medium')); + $this->assertFalse(Format::isPhpDateFormat('long')); + $this->assertFalse(Format::isPhpDateFormat('full')); + } + + public function testParseIcuShortcutPattern(): void + { + $result = Format::parse('29.05.26', 'short', 'de_DE'); + + $this->assertInstanceOf(DateInterface::class, $result); + $dt = $result->toDateTimeImmutable(); + $this->assertSame('2026', $dt->format('Y')); + $this->assertSame('05', $dt->format('m')); + $this->assertSame('29', $dt->format('d')); + } + public function testIsPhpDateFormatRejectsStrftime(): void { // isPhpDateFormat is only called after isStrftimeFormat returns false, From 9202f3c68aae3d816459830263c49767c1a8dd5a Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Fri, 22 May 2026 12:51:43 +0200 Subject: [PATCH 3/3] fix(format): remove PHP date() auto-detection from Format::parse() PHP date() single-letter patterns (u, o, etc.) are ambiguous with ICU patterns and locale shortcuts (short, long, full). Auto-detection is only reliable for strftime (% prefix) vs ICU. Format::parse() now only distinguishes strftime vs ICU, giving ICU first priority. Callers needing PHP date() parsing should use DateTimeFormatter::parse() directly. Fixes ICU shortcut misclassification (e.g. 'short' matching 'o'). --- src/Format.php | 61 ++++++----------------------- src/Formatter/DateTimeFormatter.php | 5 +++ test/Unit/FormatParseTest.php | 43 -------------------- 3 files changed, 17 insertions(+), 92 deletions(-) diff --git a/src/Format.php b/src/Format.php index a254372..688f74d 100644 --- a/src/Format.php +++ b/src/Format.php @@ -19,7 +19,6 @@ use DateTime; use DateTimeInterface; -use Horde\Date\Formatter\DateTimeFormatter; use Horde\Date\Formatter\IcuFormatter; use IntlDateFormatter; use InvalidArgumentException; @@ -310,11 +309,14 @@ public static function isStrftimeFormat(string $format): bool /** * Parse a formatted date string to a DateInterface object * - * Detects the pattern type (strftime, ICU, or PHP date()) and dispatches - * to the appropriate formatter's parse method. + * Auto-detects strftime patterns (containing %) and converts them to ICU. + * All other patterns are treated as ICU (including shortcuts like short, + * medium, long, full). + * + * For PHP date() patterns, use DateTimeFormatter::parse() directly. * * @param string $formattedString The date string to parse - * @param string $pattern Format pattern (strftime, ICU, or PHP date() syntax) + * @param string $pattern Format pattern (strftime or ICU syntax) * @param string|Stringable $locale Locale for parsing (default: 'en_US') * @param string|null $timezone Timezone identifier (null = UTC) * @@ -336,12 +338,6 @@ public static function parse( return $formatter->parse($formattedString, $icuPattern, $locale, $timezone); } - if (self::isPhpDateFormat($pattern)) { - $formatter = new DateTimeFormatter(); - return $formatter->parse($formattedString, $pattern, $locale, $timezone); - } - - // Default: treat as ICU pattern $formatter = new IcuFormatter(); return $formatter->parse($formattedString, $pattern, $locale, $timezone); } @@ -353,9 +349,13 @@ public static function parse( * the string atomically. This avoids regex-based string splitting which * breaks with AM/PM markers and other multi-word tokens. * + * PHP date() patterns are not auto-detected due to ambiguity with ICU + * single-letter patterns. Use DateTimeFormatter::parse() directly for + * PHP date() syntax. + * * @param string $formattedString The date+time string to parse - * @param string $datePattern Date format pattern (strftime, ICU, or PHP date()) - * @param string $timePattern Time format pattern (strftime, ICU, or PHP date()) + * @param string $datePattern Date format pattern (strftime or ICU) + * @param string $timePattern Time format pattern (strftime or ICU) * @param string|Stringable $locale Locale for parsing (default: 'en_US') * @param string|null $timezone Timezone identifier (null = UTC) * @@ -388,43 +388,6 @@ public static function parseDateTime( return $formatter->parse($formattedString, $combinedPattern, $locale, $timezone); } - /** - * Detect if a pattern uses PHP date() syntax (single letters like Y, m, d, H, i, s) - * - * Distinguishes from ICU patterns which use repeated letters (yyyy, MM, dd). - * A pattern is considered PHP date() if it contains characteristic PHP date - * letters that do not appear in ICU patterns as single characters. - * - * @param string $pattern Pattern to check - * @return bool True if the pattern appears to be PHP date() syntax - */ - public static function isPhpDateFormat(string $pattern): bool - { - // ICU locale shortcuts (handled by IcuFormatter, not PHP date()) - if (in_array($pattern, ['short', 'medium', 'long', 'full'], true)) { - return false; - } - - // These characters are unique to PHP date() and don't appear as single - // letters in ICU patterns in the same way - $phpOnlyChars = ['i', 'j', 'n', 'g', 'A', 'N', 'L', 'o', 'U', 'u']; - foreach ($phpOnlyChars as $char) { - if (str_contains($pattern, $char)) { - return true; - } - } - - // Single Y/m/d/H/s without repetition is PHP style - // ICU uses yyyy, MM, dd, HH, ss (repeated) - if (preg_match('/(? * @category Horde * @copyright 2026 The Horde Project diff --git a/test/Unit/FormatParseTest.php b/test/Unit/FormatParseTest.php index 615d716..a6842ab 100644 --- a/test/Unit/FormatParseTest.php +++ b/test/Unit/FormatParseTest.php @@ -51,17 +51,6 @@ public function testParseStrftimePattern(): void $this->assertSame('22', $dt->format('d')); } - public function testParsePhpDatePattern(): void - { - $result = Format::parse('2026-05-22', 'Y-m-d'); - - $this->assertInstanceOf(DateInterface::class, $result); - $dt = $result->toDateTimeImmutable(); - $this->assertSame('2026', $dt->format('Y')); - $this->assertSame('05', $dt->format('m')); - $this->assertSame('22', $dt->format('d')); - } - public function testParseWithTime24h(): void { $result = Format::parse('22.05.2026 14:30', 'dd.MM.yyyy HH:mm', 'de_DE'); @@ -168,29 +157,6 @@ public function testParseWithTimezone(): void $this->assertSame('30', $dt->format('i')); } - public function testIsPhpDateFormatDetectsPhpPatterns(): void - { - $this->assertTrue(Format::isPhpDateFormat('Y-m-d')); - $this->assertTrue(Format::isPhpDateFormat('Y-m-d H:i:s')); - $this->assertTrue(Format::isPhpDateFormat('d/m/Y')); - $this->assertTrue(Format::isPhpDateFormat('j.n.Y')); - } - - public function testIsPhpDateFormatRejectsIcuPatterns(): void - { - $this->assertFalse(Format::isPhpDateFormat('dd.MM.yyyy')); - $this->assertFalse(Format::isPhpDateFormat('yyyy-MM-dd HH:mm:ss')); - $this->assertFalse(Format::isPhpDateFormat('EEEE, MMMM dd')); - } - - public function testIsPhpDateFormatRejectsIcuShortcuts(): void - { - $this->assertFalse(Format::isPhpDateFormat('short')); - $this->assertFalse(Format::isPhpDateFormat('medium')); - $this->assertFalse(Format::isPhpDateFormat('long')); - $this->assertFalse(Format::isPhpDateFormat('full')); - } - public function testParseIcuShortcutPattern(): void { $result = Format::parse('29.05.26', 'short', 'de_DE'); @@ -201,13 +167,4 @@ public function testParseIcuShortcutPattern(): void $this->assertSame('05', $dt->format('m')); $this->assertSame('29', $dt->format('d')); } - - public function testIsPhpDateFormatRejectsStrftime(): void - { - // isPhpDateFormat is only called after isStrftimeFormat returns false, - // so it doesn't need to handle strftime patterns. But patterns without - // a % are already not strftime — verify ICU-like patterns are rejected. - $this->assertFalse(Format::isPhpDateFormat('dd.MM.yyyy')); - $this->assertFalse(Format::isPhpDateFormat('EEEE, MMMM dd')); - } }