Skip to content
Open
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
87 changes: 87 additions & 0 deletions src/Chronos.php
Original file line number Diff line number Diff line change
Expand Up @@ -660,9 +660,96 @@ public static function createFromFormat(
throw new InvalidArgumentException($message);
}

$testNow = static::getTestNow();
if ($testNow !== null) {
$dateTime = static::applyTestNowToMissingComponents($dateTime, $format, $testNow);
}

return $dateTime;
}

/**
* Apply testNow values to date/time components that weren't in the format string.
*
* @param static $dateTime The parsed datetime instance.
* @param string $format The format string used for parsing.
* @param \Cake\Chronos\Chronos $testNow The test now instance.
* @return static
*/
protected static function applyTestNowToMissingComponents(
self $dateTime,
string $format,
Chronos $testNow,
): static {
// Parse format string to find which characters are actual format specifiers (not escaped)
$formatChars = static::getFormatCharacters($format);

// Check which components are present in the format
$hasYear = (bool)array_intersect($formatChars, ['Y', 'y', 'o', 'X', 'x']);
$hasMonth = (bool)array_intersect($formatChars, ['m', 'n', 'M', 'F']);
$hasDay = (bool)array_intersect($formatChars, ['d', 'j', 'D', 'l', 'N', 'z', 'w', 'W', 'S']);
$hasHour = (bool)array_intersect($formatChars, ['H', 'G', 'h', 'g']);
$hasMinute = (bool)array_intersect($formatChars, ['i']);
$hasSecond = (bool)array_intersect($formatChars, ['s']);
$hasMicro = (bool)array_intersect($formatChars, ['u', 'v']);

// If the format includes '!' or '|', PHP resets unspecified components to Unix epoch or zero
// In that case, we should not override with testNow
$hasReset = in_array('!', $formatChars, true) || in_array('|', $formatChars, true);
if ($hasReset) {
return $dateTime;
}
Comment on lines +696 to +701
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we adding new syntax to PHP's datetime formats?

Copy link
Member Author

@dereuromark dereuromark Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're not adding new syntax - ! and | are existing PHP DateTime format modifiers (see PHP docs).

The code is detecting their presence so we know to skip applying testNow values. When a user explicitly uses ! or |, they're telling PHP to reset unspecified components to Unix epoch, so we should respect that intent rather than substituting testNow values.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOL.. never knew such modifiers existed.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Open for alternative solutions to solving this issue, though :)


// Replace missing components with testNow values
$year = $hasYear ? $dateTime->year : $testNow->year;
$month = $hasMonth ? $dateTime->month : $testNow->month;
$day = $hasDay ? $dateTime->day : $testNow->day;
$hour = $hasHour ? $dateTime->hour : $testNow->hour;
$minute = $hasMinute ? $dateTime->minute : $testNow->minute;
$second = $hasSecond ? $dateTime->second : $testNow->second;
$micro = $hasMicro ? $dateTime->micro : $testNow->micro;

// Only modify if something needs to change
if (
!$hasYear || !$hasMonth || !$hasDay ||
!$hasHour || !$hasMinute || !$hasSecond || !$hasMicro
) {
return $dateTime
->setDate($year, $month, $day)
->setTime($hour, $minute, $second, $micro);
}

return $dateTime;
}

/**
* Extract format characters from a format string, handling escapes.
*
* @param string $format The format string.
* @return array<string> Array of format characters.
*/
protected static function getFormatCharacters(string $format): array
{
$chars = [];
$length = strlen($format);
$i = 0;

while ($i < $length) {
$char = $format[$i];

// Backslash escapes the next character
if ($char === '\\' && $i + 1 < $length) {
$i += 2;
continue;
}

$chars[] = $char;
$i++;
}

return $chars;
}

/**
* Returns parse warnings and errors from the last ``createFromFormat()``
* call.
Expand Down
82 changes: 82 additions & 0 deletions tests/TestCase/DateTime/CreateFromFormatTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,88 @@ public function testCreateFromFormatReturnsInstance()
$this->assertTrue($d instanceof Chronos);
}

public function testCreateFromFormatWithTestNowMissingYear()
{
Chronos::setTestNow(new Chronos('2020-12-01 14:30:45'));
$d = Chronos::createFromFormat('m-d H:i:s', '10-05 09:15:30');
$this->assertDateTime($d, 2020, 10, 5, 9, 15, 30);
}

public function testCreateFromFormatWithTestNowMissingDate()
{
Chronos::setTestNow(new Chronos('2020-12-01 14:30:45'));
$d = Chronos::createFromFormat('H:i:s', '09:15:30');
$this->assertDateTime($d, 2020, 12, 1, 9, 15, 30);
}

public function testCreateFromFormatWithTestNowMissingTime()
{
Chronos::setTestNow(new Chronos('2020-12-01 14:30:45'));
$d = Chronos::createFromFormat('Y-m-d', '2021-06-15');
$this->assertDateTime($d, 2021, 6, 15, 14, 30, 45);
}

public function testCreateFromFormatWithTestNowPartialDate()
{
Chronos::setTestNow(new Chronos('2020-12-01 00:00:00'));
$d = Chronos::createFromFormat('m-d', '10-05');
$this->assertDateTime($d, 2020, 10, 5, 0, 0, 0);
}

public function testCreateFromFormatWithTestNowDayOnly()
{
Chronos::setTestNow(new Chronos('2020-12-01 00:00:00'));
$d = Chronos::createFromFormat('d', '05');
$this->assertDateTime($d, 2020, 12, 5, 0, 0, 0);
}

public function testCreateFromFormatWithTestNowComplete()
{
// When format is complete, testNow should not affect the result
Chronos::setTestNow(new Chronos('2020-12-01 14:30:45'));
$d = Chronos::createFromFormat('Y-m-d H:i:s', '1975-05-21 22:32:11');
$this->assertDateTime($d, 1975, 5, 21, 22, 32, 11);
}

public function testCreateFromFormatWithTestNowResetModifier()
{
// The '!' modifier resets to Unix epoch, should not use testNow
Chronos::setTestNow(new Chronos('2020-12-01 14:30:45'));
$d = Chronos::createFromFormat('!Y-m-d', '2021-06-15');
$this->assertDateTime($d, 2021, 6, 15, 0, 0, 0);
}

public function testCreateFromFormatWithTestNowPipeModifier()
{
// The '|' modifier resets unspecified components to zero, should not use testNow
Chronos::setTestNow(new Chronos('2020-12-01 14:30:45'));
$d = Chronos::createFromFormat('Y-m-d|', '2021-06-15');
$this->assertDateTime($d, 2021, 6, 15, 0, 0, 0);
}

public function testCreateFromFormatWithoutTestNow()
{
// Without testNow set, behavior should use real current time for missing components
Chronos::setTestNow(null);
$d = Chronos::createFromFormat('Y-m-d H:i:s', '1975-05-21 22:32:11');
$this->assertDateTime($d, 1975, 5, 21, 22, 32, 11);
}

public function testCreateFromFormatWithTestNowEscapedCharacters()
{
// Escaped format characters should not be treated as format specifiers
Chronos::setTestNow(new Chronos('2020-12-01 14:30:45'));
$d = Chronos::createFromFormat('\Y\-m-d', 'Y-10-05');
$this->assertDateTime($d, 2020, 10, 5, 14, 30, 45);
}

public function testCreateFromFormatWithTestNowMicroseconds()
{
Chronos::setTestNow(new Chronos('2020-12-01 14:30:45.123456'));
$d = Chronos::createFromFormat('Y-m-d H:i:s', '2021-06-15 09:15:30');
$this->assertSame(123456, $d->micro);
}

public function testCreateFromFormatWithTimezoneString()
{
$d = Chronos::createFromFormat('Y-m-d H:i:s', '1975-05-21 22:32:11', 'Europe/London');
Expand Down