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
116 changes: 116 additions & 0 deletions src/Format.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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('/(?<![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
194 changes: 194 additions & 0 deletions test/Unit/FormatParseTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
<?php

declare(strict_types=1);

/**
* Copyright 2026 The Horde Project (http://www.horde.org/)
*
* See the enclosed file LICENSE for license information (LGPL). If you
* did not receive this file, see http://www.horde.org/licenses/lgpl21.
*
* @category Horde
* @copyright 2026 The Horde Project
* @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
* @package Date
*/

namespace Horde\Date\Test\Unit;

use Horde\Date\DateInterface;
use Horde\Date\Format;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use RuntimeException;

/**
* Tests for Format::parse() and Format::parseDateTime()
*/
#[CoversClass(Format::class)]
class FormatParseTest extends TestCase
{
public function testParseIcuPattern(): void
{
$result = Format::parse('22.05.2026', 'dd.MM.yyyy', '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 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'));
}
}
Loading