From eca8882966e5bf2218ad6a7671eb14911531ae69 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Fri, 22 May 2026 11:24:13 +0200 Subject: [PATCH] feat(date): add Format::parse() and Format::parseDateTime() Static entry points that detect pattern type (strftime/ICU/PHP date) and dispatch to the appropriate formatter's parse method. parseDateTime combines separate date and time patterns atomically, fixing AM/PM regex splitting bugs in consumers. --- src/Format.php | 116 ++++++++++++++++++++ test/Unit/FormatParseTest.php | 194 ++++++++++++++++++++++++++++++++++ 2 files changed, 310 insertions(+) create mode 100644 test/Unit/FormatParseTest.php diff --git a/src/Format.php b/src/Format.php index 1608c08..b1bda62 100644 --- a/src/Format.php +++ b/src/Format.php @@ -19,9 +19,12 @@ use DateTime; use DateTimeInterface; +use Horde\Date\Formatter\DateTimeFormatter; +use Horde\Date\Formatter\IcuFormatter; use IntlDateFormatter; use InvalidArgumentException; use RuntimeException; +use Stringable; /** * Date format conversion utilities @@ -304,6 +307,119 @@ public static function isStrftimeFormat(string $format): bool return preg_match($strftimePattern, $format) === 1; } + /** + * 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. + * + * @param string $formattedString The date string to parse + * @param string $pattern Format pattern (strftime, ICU, or PHP date() syntax) + * @param string|Stringable $locale Locale for parsing (default: 'en_US') + * @param string|null $timezone Timezone identifier (null = UTC) + * + * @return DateInterface Parsed date object + * + * @throws RuntimeException if parsing fails + */ + public static function parse( + string $formattedString, + string $pattern, + string|Stringable $locale = 'en_US', + ?string $timezone = null + ): DateInterface { + $locale = (string) $locale; + + if (self::isStrftimeFormat($pattern)) { + $icuPattern = self::strftimeToIcu($pattern, $locale); + $formatter = new IcuFormatter(); + 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); + } + + /** + * Parse a formatted date+time string using separate date and time patterns + * + * Combines date and time patterns into a single ICU pattern and parses + * the string atomically. This avoids regex-based string splitting which + * breaks with AM/PM markers and other multi-word tokens. + * + * @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|Stringable $locale Locale for parsing (default: 'en_US') + * @param string|null $timezone Timezone identifier (null = UTC) + * + * @return DateInterface Parsed date object + * + * @throws RuntimeException if parsing fails + */ + public static function parseDateTime( + string $formattedString, + string $datePattern, + string $timePattern, + string|Stringable $locale = 'en_US', + ?string $timezone = null + ): DateInterface { + $locale = (string) $locale; + + // Convert both patterns to ICU if needed + $icuDate = self::isStrftimeFormat($datePattern) + ? self::strftimeToIcu($datePattern, $locale) + : $datePattern; + + $icuTime = self::isStrftimeFormat($timePattern) + ? self::strftimeToIcu($timePattern, $locale) + : $timePattern; + + // Combine into a single ICU pattern with space separator + $combinedPattern = $icuDate . ' ' . $icuTime; + + $formatter = new IcuFormatter(); + 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 + { + // 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('/(?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 testParseStrftimePattern(): void + { + $result = Format::parse('22.05.2026', '%d.%m.%Y', 'de_DE'); + + $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 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'); + + $dt = $result->toDateTimeImmutable(); + $this->assertSame('2026', $dt->format('Y')); + $this->assertSame('05', $dt->format('m')); + $this->assertSame('22', $dt->format('d')); + $this->assertSame('14', $dt->format('H')); + $this->assertSame('30', $dt->format('i')); + } + + public function testParseWithTime12hAmPm(): void + { + $result = Format::parse('05/22/2026 2:30 PM', 'MM/dd/yyyy h:mm a', 'en_US'); + + $dt = $result->toDateTimeImmutable(); + $this->assertSame('2026', $dt->format('Y')); + $this->assertSame('05', $dt->format('m')); + $this->assertSame('22', $dt->format('d')); + $this->assertSame('14', $dt->format('H')); + $this->assertSame('30', $dt->format('i')); + } + + public function testParseDateTimeWithSeparatePatterns(): void + { + $result = Format::parseDateTime('22.05.2026 14:30', 'dd.MM.yyyy', 'HH:mm', 'de_DE'); + + $dt = $result->toDateTimeImmutable(); + $this->assertSame('2026', $dt->format('Y')); + $this->assertSame('05', $dt->format('m')); + $this->assertSame('22', $dt->format('d')); + $this->assertSame('14', $dt->format('H')); + $this->assertSame('30', $dt->format('i')); + } + + public function testParseDateTimeWithAmPm(): void + { + $result = Format::parseDateTime('05/22/2026 2:30 PM', 'MM/dd/yyyy', 'h:mm a', 'en_US'); + + $dt = $result->toDateTimeImmutable(); + $this->assertSame('2026', $dt->format('Y')); + $this->assertSame('05', $dt->format('m')); + $this->assertSame('22', $dt->format('d')); + $this->assertSame('14', $dt->format('H')); + $this->assertSame('30', $dt->format('i')); + } + + public function testParseDateTimeWithStrftimePatterns(): void + { + $result = Format::parseDateTime('22.05.2026 14:30', '%d.%m.%Y', '%H:%M', 'de_DE'); + + $dt = $result->toDateTimeImmutable(); + $this->assertSame('2026', $dt->format('Y')); + $this->assertSame('22', $dt->format('d')); + $this->assertSame('14', $dt->format('H')); + $this->assertSame('30', $dt->format('i')); + } + + public function testRoundTripIcu(): void + { + $pattern = 'dd.MM.yyyy HH:mm'; + $locale = 'de_DE'; + $original = '22.05.2026 14:30'; + + $parsed = Format::parse($original, $pattern, $locale); + $formatted = Format::formatDate($parsed->toDateTimeImmutable(), $pattern, $locale); + + $this->assertSame($original, $formatted); + } + + public function testRoundTripStrftime(): void + { + $pattern = '%d.%m.%Y'; + $locale = 'de_DE'; + $original = '22.05.2026'; + + $parsed = Format::parse($original, $pattern, $locale); + $formatted = Format::formatDate($parsed->toDateTimeImmutable(), $pattern, $locale); + + $this->assertSame($original, $formatted); + } + + public function testParseInvalidStringThrowsException(): void + { + $this->expectException(RuntimeException::class); + + Format::parse('not-a-date', 'dd.MM.yyyy', 'de_DE'); + } + + public function testParseMismatchedPatternThrowsException(): void + { + $this->expectException(RuntimeException::class); + + Format::parse('hello world', 'dd.MM.yyyy', 'de_DE'); + } + + public function testParseWithTimezone(): void + { + $result = Format::parse('22.05.2026 14:30', 'dd.MM.yyyy HH:mm', 'de_DE', 'Europe/Berlin'); + + $dt = $result->toDateTimeImmutable(); + $this->assertSame('14', $dt->format('H')); + $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 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')); + } +}