Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 12 additions & 44 deletions src/Format.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@

use DateTime;
use DateTimeInterface;
use Horde\Date\Formatter\DateTimeFormatter;
use Horde\Date\Formatter\IcuFormatter;
use IntlDateFormatter;
use InvalidArgumentException;
Expand Down Expand Up @@ -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)
*
Expand All @@ -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);
}
Expand All @@ -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)
*
Expand Down Expand Up @@ -388,38 +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
{
// 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('/(?<![a-zA-Z])([YmdHsG])(?![a-zA-Z])/', $pattern)
&& !preg_match('/(yyyy|MM|dd|HH|mm|ss|EEEE|EEE)/', $pattern)
) {
return true;
}

return false;
}

/**
* Clear the conversion cache
*
Expand Down
5 changes: 5 additions & 0 deletions src/Formatter/DateTimeFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@
* Note: This formatter ignores the locale parameter as DateTime::format()
* is not locale-aware. For locale-aware formatting, use IcuFormatter.
*
* PHP date() patterns are NOT auto-detected by Format::parse() because
* single-letter PHP format characters (e.g. 'u', 'o') are ambiguous with
* ICU patterns and locale shortcuts ('short', 'long', 'full'). Callers
* that need PHP date() parsing must use this formatter directly.
*
* @author Ralf Lang <ralf.lang@ralf-lang.de>
* @category Horde
* @copyright 2026 The Horde Project
Expand Down
38 changes: 7 additions & 31 deletions test/Unit/FormatParseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -168,27 +157,14 @@ public function testParseWithTimezone(): void
$this->assertSame('30', $dt->format('i'));
}

public function testIsPhpDateFormatDetectsPhpPatterns(): void
public function testParseIcuShortcutPattern(): 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'));
}
$result = Format::parse('29.05.26', 'short', 'de_DE');

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'));
$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'));
}
}
Loading