diff --git a/config/.phpcs.xml b/config/.phpcs.xml
index a1c70fb1..4c03b766 100644
--- a/config/.phpcs.xml
+++ b/config/.phpcs.xml
@@ -30,6 +30,7 @@
error
+
diff --git a/examples/async/usleep.php b/examples/async/usleep.php
index 44b0dcad..b30f84b3 100644
--- a/examples/async/usleep.php
+++ b/examples/async/usleep.php
@@ -5,22 +5,27 @@
namespace Psl\Example\IO;
use Psl\Async;
+use Psl\DateTime;
use Psl\IO;
require __DIR__ . '/../../vendor/autoload.php';
Async\main(static function (): int {
- $start = time();
+ $start = microtime(true);
Async\concurrently([
- static fn() => Async\sleep(2.0),
- static fn() => Async\sleep(2.0),
- static fn() => Async\sleep(2.0),
+ static fn() => Async\sleep(DateTime\Duration::hours(0)),
+ static fn() => Async\sleep(DateTime\Duration::minutes(0)),
+ static fn() => Async\sleep(DateTime\Duration::zero()),
+ static fn() => Async\sleep(DateTime\Duration::seconds(2)),
+ static fn() => Async\sleep(DateTime\Duration::nanoseconds(20000000)),
+ static fn() => Async\sleep(DateTime\Duration::microseconds(200000)),
+ static fn() => Async\sleep(DateTime\Duration::milliseconds(2000)),
]);
- $duration = time() - $start;
+ $duration = DateTime\Duration::microseconds((int) ((microtime(true) - $start) * DateTime\MICROSECONDS_PER_SECOND));
- IO\write_error_line("duration: %d.", $duration);
+ IO\write_error_line("duration: %s.", $duration->toString(max_decimals: 5));
return 0;
});
diff --git a/sample.php b/sample.php
new file mode 100644
index 00000000..1c97b20e
--- /dev/null
+++ b/sample.php
@@ -0,0 +1,55 @@
+getTimezone()->getOffset($someday)->getTotalMinutes());
+ IO\write_line('The location of the timezone: %s', json_encode($someday->getTimezone()->getLocation()));
+
+ $today = DateTime\DateTime::now(DateTime\Timezone::EuropeLondon);
+ $today_native = new NativeDateTime('now', new NativeDateTimeZone('Europe/London'));
+
+ $that_day = $today->plusDays(24);
+ $that_day_native = $today_native->modify('+24 days');
+
+ var_dump($that_day->format(DateTime\DateFormat::Iso8601), $that_day_native->format(DateTimeInterface::ATOM));
+ var_dump($that_day->getTimezone()->name, $that_day_native->getTimezone()->getName());
+ var_dump($that_day->getTimezone()->getOffset($that_day), $that_day_native->getTimezone()->getOffset($that_day_native));
+
+ $now = DateTime\DateTime::now(DateTime\Timezone::EuropeLondon);
+
+ foreach (DateTime\DateFormat::cases() as $case) {
+ IO\write_line('time: %s -> %s', $case->name, $now->format(format: $case, locale: Locale\Locale::English));
+ }
+
+ IO\write_line('The offset of the timezone: %s', $now->getTimezone()->getOffset($now)->toString());
+
+ $after_4_months = $now->withMonth(4);
+ IO\write_line('The offset of the timezone: %s', $after_4_months->getTimezone()->getOffset($after_4_months)->toString());
+
+ $future = $now->withYear(2051)->withMonth(4)->withDay(2)->withHours(14)->withMinutes(51)->withSeconds(21)->withNanoseconds(124636);
+
+ $now_timestamp = $now->getTimestamp();
+ $future_timestamp = $future->getTimestamp();
+
+ var_dump($now_timestamp->compare($future_timestamp), $future->isAfter($now), $now->isBeforeOrAtTheSameTime($future));
+
+ IO\write_line('The offset of the future timezone: %s', $future->getTimezone()->getOffset($future)->toString());
+ IO\write_line('Time: %s', $now->getTimestamp()->format($now->getTimezone(), locale: Locale\Locale::French));
+
+ $now = DateTime\DateTime::now(DateTime\Timezone::EuropeLondon);
+ IO\write_line('Time: %s', json_encode($now));
+});
diff --git a/src/Psl/Async/Scheduler.php b/src/Psl/Async/Scheduler.php
index 60af0867..26831e7e 100644
--- a/src/Psl/Async/Scheduler.php
+++ b/src/Psl/Async/Scheduler.php
@@ -6,6 +6,7 @@
use Closure;
use Psl;
+use Psl\DateTime;
use Revolt\EventLoop;
use Revolt\EventLoop\Driver;
use Revolt\EventLoop\InvalidCallbackError;
@@ -114,15 +115,19 @@ public static function defer(Closure $callback): string
/**
* Delay the execution of a callback.
*
- * @param float $delay The amount of time, to delay the execution for in seconds.
+ * @param DateTime\Duration|float $delay The amount of time, to delay the execution for in seconds.
* @param Closure(string): void $callback The callback to delay.
*
* @return non-empty-string A unique identifier that can be used to cancel, enable or disable the callback.
*
* @see EventLoop::delay()
*/
- public static function delay(float $delay, Closure $callback): string
+ public static function delay(DateTime\Duration|float $delay, Closure $callback): string
{
+ if ($delay instanceof DateTime\Duration) {
+ $delay = $delay->getTotalSeconds();
+ }
+
/** @var non-empty-string */
return EventLoop::delay($delay, $callback);
}
@@ -130,19 +135,23 @@ public static function delay(float $delay, Closure $callback): string
/**
* Repeatedly execute a callback.
*
- * @param float $interval The time interval, to wait between executions in seconds.
+ * @param DateTime\Duration|float $interval The time interval, to wait between executions in seconds.
* @param Closure(string): void $callback The callback to repeat.
*
* @return non-empty-string A unique identifier that can be used to cancel, enable or disable the callback.
*
* @see EventLoop::repeat()
*/
- public static function repeat(float $interval, Closure $callback): string
+ public static function repeat(DateTime\Duration|float $interval, Closure $callback): string
{
+ if ($interval instanceof DateTime\Duration) {
+ $interval = $interval->getTotalSeconds();
+ }
/** @var non-empty-string */
return EventLoop::repeat($interval, $callback);
}
+
/**
* Enable a callback to be active starting in the next tick.
*
diff --git a/src/Psl/Async/sleep.php b/src/Psl/Async/sleep.php
index 94a338c7..109452f9 100644
--- a/src/Psl/Async/sleep.php
+++ b/src/Psl/Async/sleep.php
@@ -4,13 +4,18 @@
namespace Psl\Async;
+use Psl\DateTime;
use Revolt\EventLoop;
/**
* Non-blocking sleep for the specified number of seconds.
*/
-function sleep(float $seconds): void
+function sleep(DateTime\Duration|float $seconds): void
{
+ if ($seconds instanceof DateTime\Duration) {
+ $seconds = $seconds->getTotalSeconds();
+ }
+
$suspension = EventLoop::getSuspension();
$watcher = EventLoop::delay($seconds, static fn () => $suspension->resume());
diff --git a/src/Psl/DateTime/DateFormat.php b/src/Psl/DateTime/DateFormat.php
new file mode 100644
index 00000000..09ee71da
--- /dev/null
+++ b/src/Psl/DateTime/DateFormat.php
@@ -0,0 +1,24 @@
+
+ */
+ private int $month;
+
+ /**
+ * @var int<1, 31>
+ */
+ private int $day;
+
+ /**
+ * @var int<0, 23>
+ */
+ private int $hours;
+
+ /**
+ * @var int<0, 59>
+ */
+ private int $minutes;
+
+ /**
+ * @var int<0, 59>
+ */
+ private int $seconds;
+
+ /**
+ * @var int<0, 999999999>
+ */
+ private int $nanoseconds;
+
+ /**
+ * Constructs a new date-time instance with specified components and timezone.
+ *
+ * This constructor initializes a date-time object with the provided year, month, day, hour, minute,
+ * second, and nanosecond components within the given timezone. It ensures that all components are within their
+ * valid ranges: nanoseconds [0, 999,999,999], seconds [0, 59], minutes [0, 59], hours [0, 23], month [1, 12],
+ * and day [1, 28-31] depending on the month and leap year status. The constructor validates these components and
+ * assigns them to the instance if they are valid. If any component is out of its valid range, an
+ * `Exception\InvalidDateTimeException` is thrown.
+ *
+ * @throws Exception\InvalidArgumentException If any of the date or time components are outside their valid ranges,
+ * indicating an invalid date-time configuration.
+ */
+ private function __construct(Timezone $timezone, Timestamp $timestamp, int $year, int $month, int $day, int $hours, int $minutes, int $seconds, int $nanoseconds)
+ {
+ if (
+ $nanoseconds < 0 || $nanoseconds >= NANOSECONDS_PER_SECOND ||
+ $seconds < 0 || $seconds >= 60 ||
+ $minutes < 0 || $minutes >= 60 ||
+ $hours < 0 || $hours >= 24 ||
+ $month < 1 || $month > 12 ||
+ $day < 1 || $day > Month::from($month)->getDaysForYear($year)
+ ) {
+ throw new Exception\InvalidArgumentException('One or more components of the date-time are out of valid ranges.');
+ }
+
+ $this->timestamp = $timestamp;
+ $this->timezone = $timezone;
+ $this->year = $year;
+ $this->month = $month;
+ $this->day = $day;
+ $this->hours = $hours;
+ $this->minutes = $minutes;
+ $this->seconds = $seconds;
+ $this->nanoseconds = $nanoseconds;
+ }
+
+ /**
+ * Creates a new DateTime instance representing the current moment.
+ *
+ * This static method returns a DateTime object set to the current date and time. If a specific timezone is
+ * provided, the returned DateTime will be adjusted to reflect the date and time in that timezone. If no timezone
+ * is specified, the system's default timezone is used.
+ *
+ * Utilizing the {@see Timestamp::now()} method, this function captures the precise moment of invocation down to the
+ * nanosecond. This ensures that the {@see DateTime] instance represents an accurate and precise point in time.
+ *
+ * The method optionally accepts a timezone. If omitted, the DateTime object is created using the default system
+ * timezone, as determined by {@see Timezone::default()}. This flexibility allows for the creation of DateTime instances
+ * that are relevant in various global contexts without the need for manual timezone conversion by the caller.
+ *
+ * @pure
+ */
+ public static function now(?Timezone $timezone = null): DateTime
+ {
+ return self::fromTimestamp(Timestamp::now(), $timezone ?? Timezone::default());
+ }
+
+ /**
+ * Creates a DateTime instance for a specific time on the current day within the specified timezone.
+ *
+ * This method facilitates the creation of a {@see DateTime} object representing a precise time on today's date. It is
+ * particularly useful when you need to set a specific time of day for the current date in a given timezone. The
+ * method combines the current date context with a specific time, offering a convenient way to specify times such
+ * as "today at 14:00" in code.
+ *
+ * If a timezone is provided, the {@see DateTime} object is adjusted to reflect the specified time in that timezone. If
+ * the timezone parameter is omitted, the system's default timezone, as determined by {@see Timezone::default()}, is used.
+ *
+ * The time components (hours, minutes, seconds, nanoseconds) must be within their valid ranges. The method
+ * enforces these constraints and throws an {@see Exception\InvalidArgumentException} if any component is out of bounds.
+ *
+ * @param int<0, 23> $hours The hour component of the time, ranging from 0 to 23.
+ * @param int<0, 59> $minutes The minute component of the time, ranging from 0 to 59.
+ * @param int<0, 59> $seconds The second component of the time, defaulting to 0, and ranging from 0 to 59.
+ * @param int<0, 999999999> $nanoseconds The nanosecond component of the time, defaulting to 0, and ranging from 0 to 999,999,999.
+ *
+ * @throws Exception\InvalidArgumentException If any of the time components are outside their valid ranges,
+ * indicating an invalid date-time configuration.
+ *
+ * @pure
+ */
+ public static function todayAt(int $hours, int $minutes, int $seconds = 0, int $nanoseconds = 0, ?Timezone $timezone = null): DateTime
+ {
+ return self::now($timezone)->withTime($hours, $minutes, $seconds, $nanoseconds);
+ }
+
+ /**
+ * Creates a {@see DateTime} instance from individual date and time components.
+ *
+ * This method constructs a DateTime object based on the specified year, month, day, hour, minute, second, and nanosecond,
+ * taking into account the specified or default timezone. It validates the date and time components to ensure they form
+ * a valid date-time. If the components are invalid (e.g., 30th February), an {@see Exception\InvalidArgumentException} is thrown.
+ *
+ * In cases where the specified time occurs twice (such as during the end of daylight saving time), the earlier occurrence
+ * is returned. To obtain the later occurrence, you can adjust the returned instance using `->plusHours(1)`.
+ *
+ * @param Month|int<1, 12> $month
+ * @param int<1, 31> $day
+ * @param int<0, 23> $hours
+ * @param int<0, 59> $minutes
+ * @param int<0, 59> $seconds
+ * @param int<0, 999999999> $nanoseconds
+ *
+ * @throws Exception\InvalidArgumentException If the combination of date-time components is invalid.
+ *
+ * @pure
+ */
+ public static function fromParts(int $year, Month|int $month, int $day, int $hours = 0, int $minutes = 0, int $seconds = 0, int $nanoseconds = 0, Timezone $timezone = null): self
+ {
+ $timezone ??= Timezone::default();
+
+ return Internal\zone_override($timezone, static function () use ($timezone, $year, $month, $day, $hours, $minutes, $seconds, $nanoseconds): DateTime {
+ if ($month instanceof Month) {
+ $month = $month->value;
+ }
+
+ $timestamp = Timestamp::fromRaw(
+ mktime($hours, $minutes, $seconds, $month, $day, $year),
+ $nanoseconds,
+ );
+
+ $ret = new DateTime(
+ $timezone,
+ $timestamp,
+ $year,
+ $month,
+ $day,
+ $hours,
+ $minutes,
+ $seconds,
+ $nanoseconds,
+ );
+
+ // mktime() doesn't throw on invalid date/time, but silently returns a
+ // timestamp that doesn't match the input; so we check for that here.
+ if ($ret->getParts() !== DateTime::fromTimestamp($timestamp, $timezone)->getParts()) {
+ throw new Exception\InvalidArgumentException(Str\format(
+ 'The given components do not form a valid date-time in the timezone "%s"',
+ $timezone->value,
+ ));
+ }
+
+ return $ret;
+ });
+ }
+
+ /**
+ * Creates a {@see DateTime} instance from a timestamp, representing the same point in time.
+ *
+ * This method converts a {@see Timestamp} into a {@see DateTime} instance calculated for the specified timezone.
+ * It extracts and uses the date and time parts corresponding to the given timestamp in the provided or default timezone.
+ *
+ * @param Timezone|null $timezone The timezone for the DateTime instance. Defaults to the system's default timezone if null.
+ *
+ * @pure
+ */
+ public static function fromTimestamp(Timestamp $timestamp, ?Timezone $timezone = null): DateTime
+ {
+ $timezone ??= Timezone::default();
+
+ return Internal\zone_override($timezone, static function () use ($timezone, $timestamp): DateTime {
+ [$s, $ns] = $timestamp->toRaw();
+ $parts = getdate($s);
+
+ return new static(
+ $timezone,
+ $timestamp,
+ $parts['year'],
+ $parts['mon'],
+ $parts['mday'],
+ $parts['hours'],
+ $parts['minutes'],
+ $parts['seconds'],
+ $ns,
+ );
+ });
+ }
+
+ /**
+ * Parses a date-time string and returns a {@see DateTime} instance for the specified or default timezone.
+ *
+ * This method interprets a date-time string, potentially relative (e.g., "next Thursday", "10 minutes ago") or absolute
+ * (e.g., "2023-03-15 12:00:00"), into a {@see DateTime} object. An optional base time (`$relative_to`) can be provided to resolve
+ * relative date-time expressions. If `$relative_to` is not provided, the current system time is used as the base.
+ *
+ * @param string $raw_string The date-time string to parse.
+ * @param Timezone|null $timezone The timezone to use for the resulting DateTime instance. Defaults to the system's default timezone if null.
+ * @param TemporalInterface|null $relative_to The temporal context used to interpret relative date-time expressions. Defaults to the current system time if null.
+ *
+ * @throws Exception\InvalidArgumentException If parsing fails or the date-time string is invalid.
+ *
+ * @see https://www.php.net/manual/en/datetime.formats.php For information on supported date and time formats.
+ */
+ public static function parse(string $raw_string, ?Timezone $timezone = null, ?TemporalInterface $relative_to = null): self
+ {
+ $timezone ??= Timezone::default();
+
+ return self::fromTimestamp(
+ Timestamp::parse($raw_string, $timezone, $relative_to),
+ $timezone,
+ );
+ }
+
+ /**
+ * Returns the timestamp representation of this date time object.
+ *
+ * @mutation-free
+ */
+ public function getTimestamp(): Timestamp
+ {
+ return $this->timestamp;
+ }
+
+ /**
+ * Retrieves the year as an integer, following ISO-8601 conventions for numbering.
+ *
+ * This method returns the year part of the date. For years in the Anno Domini (AD) era, the returned value matches
+ * the Gregorian calendar year directly (e.g., 1 for AD 1, 2021 for AD 2021, etc.). For years before AD 1, the method
+ * adheres to the ISO-8601 standard, which does not use a year zero: 1 BC is represented as 0, 2 BC as -1, 3 BC as -2,
+ * and so forth. This ISO-8601 numbering facilitates straightforward mathematical operations on years across the AD/BC
+ * divide but may require conversion for user-friendly display or when interfacing with systems that use the traditional
+ * AD/BC notation.
+ *
+ * @return int The year, formatted according to ISO-8601 standards, where 1 AD is 1, 1 BC is 0, 2 BC is -1, etc.
+ *
+ * @mutation-free
+ */
+ public function getYear(): int
+ {
+ return $this->year;
+ }
+
+ /**
+ * Returns the month.
+ *
+ * @return int<1, 12>
+ *
+ * @mutation-free
+ */
+ public function getMonth(): int
+ {
+ return $this->month;
+ }
+
+ /**
+ * Returns the day.
+ *
+ * @return int<0, 31>
+ *
+ * @mutation-free
+ */
+ public function getDay(): int
+ {
+ return $this->day;
+ }
+
+ /**
+ * Returns the hours.
+ *
+ * @return int<0, 23>
+ *
+ * @mutation-free
+ */
+ public function getHours(): int
+ {
+ return $this->hours;
+ }
+
+ /**
+ * Returns the minutes.
+ *
+ * @return int<0, 59>
+ *
+ * @mutation-free
+ */
+ public function getMinutes(): int
+ {
+ return $this->minutes;
+ }
+
+ /**
+ * Returns the seconds.
+ *
+ * @return int<0, 59>
+ *
+ * @mutation-free
+ */
+ public function getSeconds(): int
+ {
+ return $this->seconds;
+ }
+
+ /**
+ * Returns the nanoseconds.
+ *
+ * @return int<0, 999999999>
+ *
+ * @mutation-free
+ */
+ public function getNanoseconds(): int
+ {
+ return $this->nanoseconds;
+ }
+
+ /**
+ * Gets the timezone associated with the date and time.
+ *
+ * @mutation-free
+ */
+ public function getTimezone(): Timezone
+ {
+ return $this->timezone;
+ }
+
+ /**
+ * Adds the specified duration to this date-time object, returning a new instance with the added duration.
+ *
+ * @throws Exception\OverflowException If adding the duration results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function plus(Duration $duration): static
+ {
+ return static::fromTimestamp($this->getTimestamp()->plus($duration), $this->timezone);
+ }
+
+ /**
+ * Subtracts the specified duration from this date-time object, returning a new instance with the subtracted duration.
+ *
+ * @throws Exception\OverflowException If subtracting the duration results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function minus(Duration $duration): static
+ {
+ return static::fromTimestamp($this->getTimestamp()->minus($duration), $this->timezone);
+ }
+
+ /**
+ * Converts the date and time to the specified timezone.
+ *
+ * @param Timezone $timezone The timezone to convert to.
+ *
+ * @mutation-free
+ */
+ public function convertToTimezone(Timezone $timezone): static
+ {
+ return static::fromTimestamp($this->getTimestamp(), $timezone);
+ }
+
+ public function jsonSerialize(): array
+ {
+ return [
+ 'timezone' => $this->timezone,
+ 'timestamp' => $this->timestamp,
+ 'year' => $this->year,
+ 'month' => $this->month,
+ 'day' => $this->day,
+ 'hours' => $this->hours,
+ 'minutes' => $this->minutes,
+ 'seconds' => $this->seconds,
+ 'nanoseconds' => $this->nanoseconds,
+ ];
+ }
+}
diff --git a/src/Psl/DateTime/DateTimeConvenienceMethodsTrait.php b/src/Psl/DateTime/DateTimeConvenienceMethodsTrait.php
new file mode 100644
index 00000000..480e133a
--- /dev/null
+++ b/src/Psl/DateTime/DateTimeConvenienceMethodsTrait.php
@@ -0,0 +1,642 @@
+equals($other) && $this->getTimezone() === $other->getTimezone();
+ }
+
+ /**
+ * Returns a new instance with the specified year.
+ *
+ * @throws Exception\InvalidArgumentException If changing the year would result in an invalid date (e.g., setting a year that turns February 29th into a date in a non-leap year).
+ *
+ * @mutation-free
+ */
+ public function withYear(int $year): static
+ {
+ return static::fromParts(
+ $year,
+ $this->getMonth(),
+ $this->getDay(),
+ $this->getHours(),
+ $this->getMinutes(),
+ $this->getSeconds(),
+ $this->getNanoseconds(),
+ $this->getTimezone(),
+ );
+ }
+
+ /**
+ * Returns a new instance with the specified month.
+ *
+ * @param Month|int<1, 12> $month
+ *
+ * @throws Exception\InvalidArgumentException If changing the month would result in an invalid date/time.
+ *
+ * @mutation-free
+ */
+ public function withMonth(Month|int $month): static
+ {
+ return static::fromParts(
+ $this->getYear(),
+ $month,
+ $this->getDay(),
+ $this->getHours(),
+ $this->getMinutes(),
+ $this->getSeconds(),
+ $this->getNanoseconds(),
+ $this->getTimezone(),
+ );
+ }
+
+ /**
+ * Returns a new instance with the specified day.
+ *
+ * @param int<1, 31> $day
+ *
+ * @throws Exception\InvalidArgumentException If changing the day would result in an invalid date/time.
+ *
+ * @mutation-free
+ */
+ public function withDay(int $day): static
+ {
+ return static::fromParts(
+ $this->getYear(),
+ $this->getMonth(),
+ $day,
+ $this->getHours(),
+ $this->getMinutes(),
+ $this->getSeconds(),
+ $this->getNanoseconds(),
+ $this->getTimezone(),
+ );
+ }
+
+ /**
+ * Returns a new instance with the specified hours.
+ *
+ * @param int<0, 23> $hours
+ *
+ * @throws Exception\InvalidArgumentException If specifying the hours would result in an invalid time (e.g., hours greater than 23).
+ *
+ * @mutation-free
+ */
+ public function withHours(int $hours): static
+ {
+ return static::fromParts(
+ $this->getYear(),
+ $this->getMonth(),
+ $this->getDay(),
+ $hours,
+ $this->getMinutes(),
+ $this->getSeconds(),
+ $this->getNanoseconds(),
+ $this->getTimezone(),
+ );
+ }
+
+ /**
+ * Returns a new instance with the specified minutes.
+ *
+ * @param int<0, 59> $minutes
+ *
+ * @throws Exception\InvalidArgumentException If specifying the minutes would result in an invalid time (e.g., minutes greater than 59).
+ *
+ * @mutation-free
+ */
+ public function withMinutes(int $minutes): static
+ {
+ return static::fromParts(
+ $this->getYear(),
+ $this->getMonth(),
+ $this->getDay(),
+ $this->getHours(),
+ $minutes,
+ $this->getSeconds(),
+ $this->getNanoseconds(),
+ $this->getTimezone(),
+ );
+ }
+
+ /**
+ * Returns a new instance with the specified seconds.
+ *
+ * @param int<0, 59> $seconds
+ *
+ * @throws Exception\InvalidArgumentException If specifying the seconds would result in an invalid time (e.g., seconds greater than 59).
+ *
+ * @mutation-free
+ */
+ public function withSeconds(int $seconds): static
+ {
+ return static::fromParts(
+ $this->getYear(),
+ $this->getMonth(),
+ $this->getDay(),
+ $this->getHours(),
+ $this->getMinutes(),
+ $seconds,
+ $this->getNanoseconds(),
+ $this->getTimezone(),
+ );
+ }
+
+ /**
+ * Returns a new instance with the specified nanoseconds.
+ *
+ * @param int<0, 999999999> $nanoseconds
+ *
+ * @throws Exception\InvalidArgumentException If specifying the nanoseconds would result in an invalid time, considering that valid nanoseconds range from 0 to 999,999,999.
+ *
+ * @mutation-free
+ */
+ public function withNanoseconds(int $nanoseconds): static
+ {
+ return static::fromParts(
+ $this->getYear(),
+ $this->getMonth(),
+ $this->getDay(),
+ $this->getHours(),
+ $this->getMinutes(),
+ $this->getSeconds(),
+ $nanoseconds,
+ $this->getTimezone(),
+ );
+ }
+
+ /**
+ * Returns a new instance with the specified date.
+ *
+ * @param Month|int<1, 12> $month
+ * @param int<1, 31> $day
+ *
+ * @throws Exception\InvalidArgumentException If specifying the date would result in an invalid date/time.
+ * This can happen if the combination of year, month, and day does not constitute a valid date (e.g., April 31st, February 29th in a non-leap year).
+ *
+ * @mutation-free
+ */
+ public function withDate(int $year, Month|int $month, int $day): static
+ {
+ return static::fromParts(
+ $year,
+ $month,
+ $day,
+ $this->getHours(),
+ $this->getMinutes(),
+ $this->getSeconds(),
+ $this->getNanoseconds(),
+ $this->getTimezone(),
+ );
+ }
+
+ /**
+ * Returns a new instance with the specified time.
+ *
+ * @param int<0, 23> $hours
+ * @param int<0, 59> $minutes
+ * @param int<0, 59> $seconds
+ * @param int<0, 999999999> $nanoseconds
+ *
+ * @throws Exception\InvalidArgumentException If specifying the time would result in an invalid time (e.g., hours greater than 23, minutes or seconds greater than 59).
+ *
+ * @mutation-free
+ */
+ public function withTime(int $hours, int $minutes, int $seconds = 0, int $nanoseconds = 0): static
+ {
+ return static::fromParts(
+ $this->getYear(),
+ $this->getMonth(),
+ $this->getDay(),
+ $hours,
+ $minutes,
+ $seconds,
+ $nanoseconds,
+ $this->getTimezone(),
+ );
+ }
+
+ /**
+ * Returns the date (year, month, day).
+ *
+ * @return array{int, int<1, 12>, int<1, 31>} The date.
+ *
+ * @mutation-free
+ */
+ public function getDate(): array
+ {
+ return [$this->getYear(), $this->getMonth(), $this->getDay()];
+ }
+
+ /**
+ * Returns the time (hours, minutes, seconds, nanoseconds).
+ *
+ * @return array{
+ * int<0, 23>,
+ * int<0, 59>,
+ * int<0, 59>,
+ * int<0, 999999999>,
+ * }
+ *
+ * @mutation-free
+ */
+ public function getTime(): array
+ {
+ return [
+ $this->getHours(),
+ $this->getMinutes(),
+ $this->getSeconds(),
+ $this->getNanoseconds(),
+ ];
+ }
+
+ /**
+ * Returns the date and time parts (year, month, day, hours, minutes, seconds, nanoseconds).
+ *
+ * @return array{
+ * int,
+ * int<1, 12>,
+ * int<1, 31>,
+ * int<0, 23>,
+ * int<0, 59>,
+ * int<0, 59>,
+ * int<0, 999999999>,
+ * }
+ *
+ * @mutation-free
+ */
+ public function getParts(): array
+ {
+ return [
+ $this->getYear(),
+ $this->getMonth(),
+ $this->getDay(),
+ $this->getHours(),
+ $this->getMinutes(),
+ $this->getSeconds(),
+ $this->getNanoseconds(),
+ ];
+ }
+
+ /**
+ * Retrieves the era of the date represented by this DateTime instance.
+ *
+ * This method returns an instance of the `Era` enum, which indicates whether the date
+ * falls in the Anno Domini (AD) or Before Christ (BC) era. The era is determined based on the year
+ * of the date this object represents, with years designated as BC being negative
+ * and years in AD being positive.
+ *
+ * @mutation-free
+ */
+ public function getEra(): Era
+ {
+ return Era::fromYear($this->getYear());
+ }
+
+ /**
+ * Returns the century number for the year stored in this object.
+ *
+ * @mutation-free
+ */
+ public function getCentury(): int
+ {
+ return (int)($this->getYear() / 100) + 1;
+ }
+
+ /**
+ * Returns the short format of the year (last 2 digits).
+ *
+ * @return int<0, 99> The short format of the year.
+ *
+ * @mutation-free
+ */
+ public function getYearShort(): int
+ {
+ return $this->getYear() % 100;
+ }
+
+ /**
+ * Returns the hours using the 12-hour format (1 to 12) along with the meridiem indicator.
+ *
+ * @return array{int<1, 12>, Meridiem} The hours and meridiem indicator.
+ *
+ * @mutation-free
+ */
+ public function getTwelveHours(): array
+ {
+ return [
+ ($this->getHours() % 12 ?: 12),
+ ($this->getHours() < 12 ? Meridiem::AnteMeridiem : Meridiem::PostMeridiem),
+ ];
+ }
+
+ /**
+ * Retrieves the ISO-8601 year and week number corresponding to the date.
+ *
+ * This method returns an array consisting of two integers: the first represents the year, and the second
+ * represents the week number according to ISO-8601 standards, which ranges from 1 to 53. The week numbering
+ * follows the ISO-8601 specification, where a week starts on a Monday and the first week of the year is the
+ * one that contains at least four days of the new year.
+ *
+ * Due to the ISO-8601 week numbering rules, the returned year might not always match the Gregorian year
+ * obtained from `$this->getYear()`. Specifically:
+ *
+ * - The first few days of January might belong to the last week of the previous year if they fall before
+ * the first Thursday of January.
+ *
+ * - Conversely, the last days of December might be part of the first week of the following year if they
+ * extend beyond the last Thursday of December.
+ *
+ * Examples:
+ * - For the date 2020-01-01, it returns [2020, 1], indicating the first week of 2020.
+ * - For the date 2021-01-01, it returns [2020, 53], showing that this day is part of the last week of 2020
+ * according to ISO-8601.
+ *
+ * @return array{int, int<1, 53>}
+ *
+ * @mutation-free
+ */
+ public function getISOWeekNumber(): array
+ {
+ $year = $this->getYear();
+ $week = (int)$this->format('W'); // Correct format specifier for ISO-8601 week number is 'W', not '%V'
+
+ // Adjust the year based on ISO week numbering rules
+ if ($week === 1 && $this->getMonth() === 12) {
+ ++$year; // Belongs to the first week of the next year
+ } elseif ($week > 50 && $this->getMonth() === 1) {
+ --$year; // Belongs to the last week of the previous year
+ }
+
+ return [$year, $week];
+ }
+
+ /**
+ * Gets the weekday of the date.
+ *
+ * @return Weekday The weekday.
+ *
+ * @mutation-free
+ */
+ public function getWeekday(): Weekday
+ {
+ return Weekday::from((int)$this->format('%u'));
+ }
+
+ /**
+ * Checks if the date and time is in daylight saving time.
+ *
+ * @return bool True if in daylight saving time, false otherwise.
+ *
+ * @mutation-free
+ */
+ public function isDaylightSavingTime(): bool
+ {
+ return Internal\zone_override($this->getTimezone(), function (): bool {
+ return date('I', $this->getTimestamp()->getSeconds()) === '1';
+ });
+ }
+
+ /**
+ * Returns whether the year stored in this DateTime object is a leap year
+ * (has 366 days including February 29).
+ */
+ public function isLeapYear(): bool
+ {
+ return DateTime\is_leap_year($this->year);
+ }
+
+ /**
+ * Adds the specified years to this date-time object, returning a new instance with the added years.
+ *
+ * @throws Exception\UnderflowException If adding the years results in an arithmetic underflow.
+ * @throws Exception\OverflowException If adding the years results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function plusYears(int $years): static
+ {
+ if ($years < 0) {
+ return $this->minusYears(-$years);
+ }
+
+ $current_year = $this->getYear();
+
+ // Check for potential overflow when adding years
+ if ($current_year >= Math\INT64_MAX - $years || $current_year <= Math\INT64_MIN - $years) {
+ throw new Exception\OverflowException("Adding years results in a year that exceeds the representable integer range.");
+ }
+
+ $target_year = $current_year + $years;
+
+ // Handle the day and month for the target year, considering leap years
+ $current_month = $this->getMonth();
+ $current_day = $this->getDay();
+
+ $days_in_target_month = Month::from($current_month)->getDaysForYear($target_year);
+ // February 29 adjustment for non-leap target years
+ $target_day = Math\minva($days_in_target_month, $current_day);
+
+ return $this->withDate($target_year, $current_month, $target_day);
+ }
+
+ /**
+ * Subtracts the specified years from this date-time object, returning a new instance with the subtracted years.
+ *
+ * @throws Exception\UnderflowException If subtracting the years results in an arithmetic underflow.
+ *
+ * @mutation-free
+ */
+ public function minusYears(int $years): static
+ {
+ if ($years < 0) {
+ return $this->plusYears(-$years);
+ }
+
+ $current_year = $this->getYear();
+
+ // Check for potential underflow when subtracting years
+ if ($current_year <= Math\INT64_MIN + $years) {
+ throw new Exception\UnderflowException("Subtracting years results in a year that underflows the representable integer range.");
+ }
+
+ $target_year = $current_year - $years;
+
+ $current_month = $this->getMonth();
+ $current_day = $this->getDay();
+
+ $days_in_target_month = Month::from($current_month)->getDaysForYear($target_year);
+ $target_day = Math\minva($current_day, $days_in_target_month);
+
+ return $this->withDate($target_year, $current_month, $target_day);
+ }
+
+ /**
+ * Adds the specified months to this date-time object, returning a new instance with the added months.
+ *
+ * @throws Exception\UnderflowException If adding the months results in an arithmetic underflow.
+ * @throws Exception\OverflowException If adding the months results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function plusMonths(int $months): static
+ {
+ if ($months < 0) {
+ return $this->minusMonths(-$months);
+ }
+
+ $current_day = $this->getDay();
+ $current_month = $this->getMonth();
+ $current_year = $this->getYear();
+
+ if ($current_year >= Math\INT64_MAX) {
+ throw new Exception\OverflowException("Cannot add months to the maximum year.");
+ }
+
+ // Calculate target month and year
+ $total_months = $current_month + $months;
+ $target_year = $current_year + Math\div($total_months - 1, 12);
+ if ($target_year >= Math\INT64_MAX || $target_year <= Math\INT64_MIN) {
+ throw new Exception\OverflowException("Adding months results in a year that exceeds the representable integer range.");
+ }
+
+ $target_month = $total_months % 12;
+ if ($target_month === 0) {
+ $target_month = 12;
+ --$target_year; // Adjust if the modulo brings us back to December of the previous year
+ }
+
+ // Days adjustment for end-of-month scenarios
+ $days_in_target_month = Month::from($target_month)->getDaysForYear($target_year);
+
+ $target_day = Math\minva($current_day, $days_in_target_month);
+
+ // Assuming withDate properly constructs a new instance
+ return $this->withDate($target_year, $target_month, $target_day);
+ }
+
+ /**
+ * Subtracts the specified months from this date-time object, returning a new instance with the subtracted months.
+ *
+ * @throws Exception\UnderflowException If subtracting the months results in an arithmetic underflow.
+ * @throws Exception\OverflowException If subtracting the months results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function minusMonths(int $months): static
+ {
+ if ($months < 0) {
+ return $this->plusMonths(-$months);
+ }
+
+ $current_day = $this->getDay();
+ $current_month = $this->getMonth();
+ $current_year = $this->getYear();
+
+ // Calculate how many years to subtract based on the months
+ $years_to_subtract = Math\div($months, 12);
+ $months_to_subtract_after_years = $months % 12;
+
+ // Check for potential underflow when subtracting months
+ if ($current_year <= Math\INT64_MIN + $years_to_subtract) {
+ throw new Exception\UnderflowException("Subtracting months results in a year that underflows the representable integer range.");
+ }
+
+ $new_year = $current_year - $years_to_subtract;
+ $new_month = $current_month - $months_to_subtract_after_years;
+
+ // Adjust if new_month goes below 1 (January)
+ if ($new_month < 1) {
+ $new_month += 12; // Cycle back to December
+ --$new_year; // Adjust the year since we cycled back
+ }
+
+ // Ensure year adjustment didn't underflow
+ if ($new_year < Math\INT64_MIN) {
+ throw new Exception\UnderflowException("Subtracting months results in a year that underflows the representable integer range.");
+ }
+
+ $days_in_new_month = Month::from($new_month)->getDaysForYear($new_year);
+ $new_day = Math\minva($current_day, $days_in_new_month);
+
+ return $this->withDate($new_year, $new_month, $new_day);
+ }
+
+ /**
+ * Adds the specified days to this date-time object, returning a new instance with the added days.
+ *
+ * @throws Exception\UnderflowException If adding the days results in an arithmetic underflow.
+ * @throws Exception\OverflowException If adding the days results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function plusDays(int $days): static
+ {
+ return $this->plusHours(HOURS_PER_DAY * $days);
+ }
+
+ /**
+ * Subtracts the specified days from this date-time object, returning a new instance with the subtracted days.
+ *
+ * @throws Exception\UnderflowException If subtracting the days results in an arithmetic underflow.
+ * @throws Exception\OverflowException If subtracting the days results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function minusDays(int $days): static
+ {
+ return $this->plusDays(-$days);
+ }
+
+ /**
+ * Formats the date and time according to the specified format and locale.
+ *
+ * @param DateFormat|string|null $format The format string or {@see DateFormat}. If null, the default format is used.
+ * @param Locale\Locale|null $locale The locale for formatting. If null, the default locale is used.
+ *
+ * @mutation-free
+ */
+ public function format(DateFormat|string|null $format = null, ?Locale\Locale $locale = null): string
+ {
+ return Internal\zone_override($this->getTimezone(), function () use ($locale, $format): string {
+ $obj = new NativeDateTime();
+ $obj->setTimestamp($this->getTimestamp()->getSeconds());
+ $obj->setTimezone(new NativeDateTimeZone($this->getTimezone()->value));
+
+ if ($format instanceof DateFormat) {
+ $format = $format->value;
+ }
+
+ return IntlDateFormatter::formatObject($obj, $format, $locale?->value);
+ });
+ }
+}
diff --git a/src/Psl/DateTime/DateTimeInterface.php b/src/Psl/DateTime/DateTimeInterface.php
new file mode 100644
index 00000000..da4bb11b
--- /dev/null
+++ b/src/Psl/DateTime/DateTimeInterface.php
@@ -0,0 +1,409 @@
+ $month
+ * @param int<1, 31> $day
+ *
+ * @throws Exception\InvalidArgumentException If specifying the date would result in an invalid date/time.
+ * This can happen if the combination of year, month, and day does not constitute a valid date (e.g., April 31st, February 29th in a non-leap year).
+ *
+ * @mutation-free
+ */
+ public function withDate(int $year, Month|int $month, int $day): static;
+
+ /**
+ * Returns a new instance with the specified time.
+ *
+ * @param int<0, 23> $hours
+ * @param int<0, 59> $minutes
+ * @param int<0, 59> $seconds
+ * @param int<0, 999999999> $nanoseconds
+ *
+ * @throws Exception\InvalidArgumentException If specifying the time would result in an invalid time (e.g., hours greater than 23, minutes or seconds greater than 59).
+ *
+ * @mutation-free
+ */
+ public function withTime(int $hours, int $minutes, int $seconds = 0, int $nanoseconds = 0): static;
+
+ /**
+ * Returns a new instance with the specified year.
+ *
+ * @throws Exception\InvalidArgumentException If changing the year would result in an invalid date (e.g., setting a year that turns February 29th into a date in a non-leap year).
+ *
+ * @mutation-free
+ */
+ public function withYear(int $year): static;
+
+ /**
+ * Returns a new instance with the specified month.
+ *
+ * @param Month|int<1, 12> $month
+ *
+ * @throws Exception\InvalidArgumentException If changing the month would result in an invalid date/time.
+ *
+ * @mutation-free
+ */
+ public function withMonth(Month|int $month): static;
+
+ /**
+ * Returns a new instance with the specified day.
+ *
+ * @param int<1, 31> $day
+ *
+ * @throws Exception\InvalidArgumentException If changing the day would result in an invalid date/time.
+ *
+ * @mutation-free
+ */
+ public function withDay(int $day): static;
+
+ /**
+ * Returns a new instance with the specified hours.
+ *
+ * @param int<0, 23> $hours
+ *
+ * @throws Exception\InvalidArgumentException If specifying the hours would result in an invalid time (e.g., hours greater than 23).
+ *
+ * @mutation-free
+ */
+ public function withHours(int $hours): static;
+
+ /**
+ * Returns a new instance with the specified minutes.
+ *
+ * @param int<0, 59> $minutes
+ *
+ * @throws Exception\InvalidArgumentException If specifying the minutes would result in an invalid time (e.g., minutes greater than 59).
+ *
+ * @mutation-free
+ */
+ public function withMinutes(int $minutes): static;
+
+ /**
+ * Returns a new instance with the specified seconds.
+ *
+ * @param int<0, 59> $seconds
+ *
+ * @throws Exception\InvalidArgumentException If specifying the seconds would result in an invalid time (e.g., seconds greater than 59).
+ *
+ * @mutation-free
+ */
+ public function withSeconds(int $seconds): static;
+
+ /**
+ * Returns a new instance with the specified nanoseconds.
+ *
+ * @param int<0, 999999999> $nanoseconds
+ *
+ * @throws Exception\InvalidArgumentException If specifying the nanoseconds would result in an invalid time, considering that valid nanoseconds range from 0 to 999,999,999.
+ *
+ * @mutation-free
+ */
+ public function withNanoseconds(int $nanoseconds): static;
+
+ /**
+ * Returns the date (year, month, day).
+ *
+ * @return array{int, int<1, 12>, int<1, 31>} The date.
+ *
+ * @mutation-free
+ */
+ public function getDate(): array;
+
+ /**
+ * Returns the time (hours, minutes, seconds, nanoseconds).
+ *
+ * @return array{
+ * int<0, 23>,
+ * int<0, 59>,
+ * int<0, 59>,
+ * int<0, 999999999>,
+ * }
+ *
+ * @mutation-free
+ */
+ public function getTime(): array;
+
+ /**
+ * Returns the date and time parts (year, month, day, hours, minutes, seconds, nanoseconds).
+ *
+ * @return array{
+ * int,
+ * int<1, 12>,
+ * int<1, 31>,
+ * int<0, 23>,
+ * int<0, 59>,
+ * int<0, 59>,
+ * int<0, 999999999>,
+ * }
+ *
+ * @mutation-free
+ */
+ public function getParts(): array;
+
+ /**
+ * Retrieves the era of the date represented by this DateTime instance.
+ *
+ * This method returns an instance of the `Era` enum, which indicates whether the date
+ * falls in the Anno Domini (AD) or Before Christ (BC) era. The era is determined based on the year
+ * of the date this object represents, with years designated as BC being negative
+ * and years in AD being positive.
+ *
+ * @mutation-free
+ */
+ public function getEra(): Era;
+
+ /**
+ * Returns the century number for the year stored in this object.
+ *
+ * @mutation-free
+ */
+ public function getCentury(): int;
+
+ /**
+ * Retrieves the year as an integer, following ISO-8601 conventions for numbering.
+ *
+ * This method returns the year part of the date. For years in the Anno Domini (AD) era, the returned value matches
+ * the Gregorian calendar year directly (e.g., 1 for AD 1, 2021 for AD 2021, etc.). For years before AD 1, the method
+ * adheres to the ISO-8601 standard, which does not use a year zero: 1 BC is represented as 0, 2 BC as -1, 3 BC as -2,
+ * and so forth. This ISO-8601 numbering facilitates straightforward mathematical operations on years across the AD/BC
+ * divide but may require conversion for user-friendly display or when interfacing with systems that use the traditional
+ * AD/BC notation.
+ *
+ * @return int The year, formatted according to ISO-8601 standards, where 1 AD is 1, 1 BC is 0, 2 BC is -1, etc.
+ *
+ * @mutation-free
+ */
+ public function getYear(): int;
+
+ /**
+ * Returns the short format of the year (last 2 digits).
+ *
+ * @return int<00, 99> The short format of the year.
+ *
+ * @mutation-free
+ */
+ public function getYearShort(): int;
+
+ /**
+ * Returns the month.
+ *
+ * @return int<1, 12>
+ *
+ * @mutation-free
+ */
+ public function getMonth(): int;
+
+ /**
+ * Returns the day.
+ *
+ * @return int<0, 31>
+ *
+ * @mutation-free
+ */
+ public function getDay(): int;
+
+ /**
+ * Returns the hours.
+ *
+ * @return int<0, 23>
+ *
+ * @mutation-free
+ */
+ public function getHours(): int;
+
+ /**
+ * Returns the hours using the 12-hour format (1 to 12) along with the meridiem indicator.
+ *
+ * @return array{int<1, 12>, Meridiem} The hours and meridiem indicator.
+ *
+ * @mutation-free
+ */
+ public function getTwelveHours(): array;
+
+ /**
+ * Returns the minutes.
+ *
+ * @return int<0, 59>
+ *
+ * @mutation-free
+ */
+ public function getMinutes(): int;
+
+ /**
+ * Returns the seconds.
+ *
+ * @return int<0, 59>
+ *
+ * @mutation-free
+ */
+ public function getSeconds(): int;
+
+ /**
+ * Returns the nanoseconds.
+ *
+ * @return int<0, 999999999>
+ *
+ * @mutation-free
+ */
+ public function getNanoseconds(): int;
+
+ /**
+ * Retrieves the ISO-8601 year and week number corresponding to the date.
+ *
+ * This method returns an array consisting of two integers: the first represents the year, and the second
+ * represents the week number according to ISO-8601 standards, which ranges from 1 to 53. The week numbering
+ * follows the ISO-8601 specification, where a week starts on a Monday and the first week of the year is the
+ * one that contains at least four days of the new year.
+ *
+ * Due to the ISO-8601 week numbering rules, the returned year might not always match the Gregorian year
+ * obtained from `$this->getYear()`. Specifically:
+ *
+ * - The first few days of January might belong to the last week of the previous year if they fall before
+ * the first Thursday of January.
+ *
+ * - Conversely, the last days of December might be part of the first week of the following year if they
+ * extend beyond the last Thursday of December.
+ *
+ * Examples:
+ * - For the date 2020-01-01, it returns [2020, 1], indicating the first week of 2020.
+ * - For the date 2021-01-01, it returns [2020, 53], showing that this day is part of the last week of 2020
+ * according to ISO-8601.
+ *
+ * @return array{int, int<1, 53>}
+ *
+ * @mutation-free
+ */
+ public function getISOWeekNumber(): array;
+
+ /**
+ * Gets the timezone associated with the date and time.
+ *
+ * @mutation-free
+ */
+ public function getTimezone(): Timezone;
+
+ /**
+ * Gets the weekday of the date.
+ *
+ * @return Weekday The weekday.
+ *
+ * @mutation-free
+ */
+ public function getWeekday(): Weekday;
+
+ /**
+ * Checks if the date and time is in daylight saving time.
+ *
+ * @return bool True if in daylight saving time, false otherwise.
+ *
+ * @mutation-free
+ */
+ public function isDaylightSavingTime(): bool;
+
+ /**
+ * Checks if the year is a leap year.
+ *
+ * @mutation-free
+ */
+ public function isLeapYear(): bool;
+
+ /**
+ * Adds the specified years to this date-time object, returning a new instance with the added years.
+ *
+ * @throws Exception\UnderflowException If adding the years results in an arithmetic underflow.
+ * @throws Exception\OverflowException If adding the years results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function plusYears(int $years): static;
+
+ /**
+ * Adds the specified months to this date-time object, returning a new instance with the added months.
+ *
+ * @throws Exception\UnderflowException If adding the months results in an arithmetic underflow.
+ * @throws Exception\OverflowException If adding the months results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function plusMonths(int $months): static;
+
+ /**
+ * Adds the specified days to this date-time object, returning a new instance with the added days.
+ *
+ * @throws Exception\UnderflowException If adding the days results in an arithmetic underflow.
+ * @throws Exception\OverflowException If adding the days results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function plusDays(int $days): static;
+
+ /**
+ * Subtracts the specified years from this date-time object, returning a new instance with the subtracted years.
+ *
+ * @throws Exception\UnderflowException If subtracting the years results in an arithmetic underflow.
+ * @throws Exception\OverflowException If subtracting the years results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function minusYears(int $years): static;
+
+ /**
+ * Subtracts the specified months from this date-time object, returning a new instance with the subtracted months.
+ *
+ * @throws Exception\UnderflowException If subtracting the months results in an arithmetic underflow.
+ * @throws Exception\OverflowException If subtracting the months results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function minusMonths(int $months): static;
+
+ /**
+ * Subtracts the specified days from this date-time object, returning a new instance with the subtracted days.
+ *
+ * @throws Exception\UnderflowException If subtracting the days results in an arithmetic underflow.
+ * @throws Exception\OverflowException If subtracting the days results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function minusDays(int $days): static;
+
+ /**
+ * Converts the date and time to the specified timezone.
+ *
+ * @param Timezone $timezone The timezone to convert to.
+ *
+ * @mutation-free
+ */
+ public function convertToTimezone(Timezone $timezone): static;
+
+ /**
+ * Formats the date and time according to the specified format and locale.
+ *
+ * @param DateFormat|string|null $format The format string or {@see DateFormat}. If null, the default format is used.
+ * @param Locale\Locale|null $locale The locale for formatting. If null, the default locale is used.
+ *
+ * @mutation-free
+ */
+ public function format(DateFormat|string|null $format = null, ?Locale\Locale $locale = null): string;
+}
diff --git a/src/Psl/DateTime/Duration.php b/src/Psl/DateTime/Duration.php
new file mode 100644
index 00000000..c58b997d
--- /dev/null
+++ b/src/Psl/DateTime/Duration.php
@@ -0,0 +1,722 @@
+
+ * @implements Comparison\Equable
+ *
+ * @immutable
+ */
+final class Duration implements Comparison\Comparable, Comparison\Equable, JsonSerializable, Stringable
+{
+ /**
+ * Initializes a new instance of Duration with specified hours, minutes, seconds, and
+ * nanoseconds.
+ *
+ * @param int $hours
+ * @param int<-59, 59> $minutes
+ * @param int<-59, 59> $seconds
+ * @param int<-999999999, 999999999> $nanoseconds
+ */
+ private function __construct(
+ private readonly int $hours,
+ private readonly int $minutes,
+ private readonly int $seconds,
+ private readonly int $nanoseconds
+ ) {
+ }
+
+ /**
+ * Returns an instance representing the specified number of hours (and
+ * optionally minutes, seconds, nanoseconds). Due to normalization, the
+ * actual values in the returned instance may differ from the provided ones.
+ *
+ * @pure
+ */
+ public static function fromParts(int $hours, int $minutes = 0, int $seconds = 0, int $nanoseconds = 0): self
+ {
+ // This is where the normalization happens.
+ $s = SECONDS_PER_HOUR * $hours + SECONDS_PER_MINUTE * $minutes + $seconds + (int)($nanoseconds / NANOSECONDS_PER_SECOND);
+ $ns = $nanoseconds % NANOSECONDS_PER_SECOND;
+ if ($s < 0 && $ns > 0) {
+ ++$s;
+ $ns -= NANOSECONDS_PER_SECOND;
+ } elseif ($s > 0 && $ns < 0) {
+ --$s;
+ $ns += NANOSECONDS_PER_SECOND;
+ }
+
+ $m = (int)($s / 60);
+ $s %= 60;
+ $h = (int)($m / 60);
+ $m %= 60;
+ return new self($h, $m, $s, $ns);
+ }
+
+ /**
+ * Returns an instance representing the specified number of weeks, in hours.
+ *
+ * For example, `Duration::weeks(1)` is equivalent to `Duration::hours(168)`.
+ *
+ * @pure
+ */
+ public static function weeks(int $weeks): self
+ {
+ return self::fromParts($weeks * HOURS_PER_WEEK);
+ }
+
+ /**
+ * Returns an instance representing the specified number of days, in hours.
+ *
+ * For example, `Duration::days(2)` is equivalent to `Duration::hours(48)`.
+ *
+ * @pure
+ */
+ public static function days(int $days): self
+ {
+ return self::fromParts($days * HOURS_PER_DAY);
+ }
+
+ /**
+ * Returns an instance representing the specified number of hours.
+ *
+ * @pure
+ */
+ public static function hours(int $hours): self
+ {
+ return self::fromParts($hours);
+ }
+
+ /**
+ * Returns an instance representing the specified number of minutes. Due to
+ * normalization, the actual value in the returned instance may differ from
+ * the provided one, and the resulting instance may contain larger units.
+ *
+ * For example, `Duration::minutes(63)` normalizes to "1 hour(s), 3 minute(s)".
+ *
+ * @pure
+ */
+ public static function minutes(int $minutes): self
+ {
+ return self::fromParts(0, $minutes);
+ }
+
+ /**
+ * Returns an instance representing the specified number of seconds. Due to
+ * normalization, the actual value in the returned instance may differ from
+ * the provided one, and the resulting instance may contain larger units.
+ *
+ * For example, `Duration::seconds(63)` normalizes to "1 minute(s), 3 second(s)".
+ *
+ * @pure
+ */
+ public static function seconds(int $seconds): self
+ {
+ return self::fromParts(0, 0, $seconds);
+ }
+
+ /**
+ * Returns an instance representing the specified number of milliseconds (ms).
+ * The value is converted and stored as nanoseconds, since that is the only
+ * unit smaller than a second that we support. Due to normalization, the
+ * resulting instance may contain larger units.
+ *
+ * For example, `Duration::milliseconds(8042)` normalizes to "8 second(s), 42000000 nanosecond(s)".
+ *
+ * @pure
+ */
+ public static function milliseconds(int $milliseconds): self
+ {
+ return self::fromParts(0, 0, 0, NANOSECONDS_PER_MILLISECOND * $milliseconds);
+ }
+
+ /**
+ * Returns an instance representing the specified number of microseconds (us).
+ * The value is converted and stored as nanoseconds, since that is the only
+ * unit smaller than a second that we support. Due to normalization, the
+ * resulting instance may contain larger units.
+ *
+ * For example, `Duration::microseconds(8000042)` normalizes to "8 second(s), 42000 nanosecond(s)".
+ *
+ * @pure
+ */
+ public static function microseconds(int $microseconds): self
+ {
+ return self::fromParts(0, 0, 0, NANOSECONDS_PER_MICROSECOND * $microseconds);
+ }
+
+ /**
+ * Returns an instance representing the specified number of nanoseconds (ns).
+ * Due to normalization, the resulting instance may contain larger units.
+ *
+ * For example, `Duration::nanoseconds(8000000042)` normalizes to "8 second(s), 42 nanosecond(s)".
+ *
+ * @pure
+ */
+ public static function nanoseconds(int $nanoseconds): self
+ {
+ return self::fromParts(0, 0, 0, $nanoseconds);
+ }
+
+ /**
+ * Returns an instance with all parts equal to 0.
+ *
+ * @pure
+ */
+ public static function zero(): self
+ {
+ return new self(0, 0, 0, 0);
+ }
+
+ /**
+ * Compiles and returns the duration's components (hours, minutes, seconds, nanoseconds) in an
+ * array, in descending order of significance.
+ *
+ * @return array{int, int, int, int}
+ *
+ * @mutation-free
+ */
+ public function getParts(): array
+ {
+ return [$this->hours, $this->minutes, $this->seconds, $this->nanoseconds];
+ }
+
+ /**
+ * Returns the "hours" part of this time duration.
+ *
+ * @mutation-free
+ */
+ public function getHours(): int
+ {
+ return $this->hours;
+ }
+
+ /**
+ * Returns the "minutes" part of this time duration.
+ *
+ * @mutation-free
+ */
+ public function getMinutes(): int
+ {
+ return $this->minutes;
+ }
+
+ /**
+ * Returns the "seconds" part of this time duration.
+ *
+ * @mutation-free
+ */
+ public function getSeconds(): int
+ {
+ return $this->seconds;
+ }
+
+ /**
+ * Returns the "nanoseconds" part of this time duration.
+ *
+ * @mutation-free
+ */
+ public function getNanoseconds(): int
+ {
+ return $this->nanoseconds;
+ }
+
+ /**
+ * Computes, and returns the total duration of the instance in hours as a floating-point number,
+ * including any fractional parts.
+ *
+ * @mutation-free
+ */
+ public function getTotalHours(): float
+ {
+ return $this->hours + ($this->minutes / MINUTES_PER_HOUR) +
+ ($this->seconds / SECONDS_PER_HOUR) +
+ ($this->nanoseconds / (SECONDS_PER_HOUR * NANOSECONDS_PER_SECOND));
+ }
+
+ /**
+ * Computes, and returns the total duration of the instance in minutes as a floating-point number,
+ * including any fractional parts.
+ *
+ * @mutation-free
+ */
+ public function getTotalMinutes(): float
+ {
+ return ($this->hours * MINUTES_PER_HOUR) +
+ $this->minutes + ($this->seconds / SECONDS_PER_MINUTE) +
+ ($this->nanoseconds / (SECONDS_PER_MINUTE * NANOSECONDS_PER_SECOND));
+ }
+
+ /**
+ * Computes, and returns the total duration of the instance in seconds as a floating-point number,
+ * including any fractional parts.
+ *
+ * @mutation-free
+ */
+ public function getTotalSeconds(): float
+ {
+ return $this->seconds +
+ ($this->minutes * SECONDS_PER_MINUTE) +
+ ($this->hours * SECONDS_PER_HOUR) +
+ ($this->nanoseconds / NANOSECONDS_PER_SECOND);
+ }
+
+ /**
+ * Computes, and returns the total duration of the instance in milliseconds as a floating-point number,
+ * including any fractional parts.
+ *
+ * @mutation-free
+ */
+ public function getTotalMilliseconds(): float
+ {
+ return ($this->hours * SECONDS_PER_HOUR * MILLISECONDS_PER_SECOND) +
+ ($this->minutes * SECONDS_PER_MINUTE * MILLISECONDS_PER_SECOND) +
+ ($this->seconds * MILLISECONDS_PER_SECOND) +
+ ($this->nanoseconds / NANOSECONDS_PER_MILLISECOND);
+ }
+
+ /**
+ * Computes, and returns the total duration of the instance in microseconds as a floating-point number,
+ * including any fractional parts.
+ *
+ * @mutation-free
+ */
+ public function getTotalMicroseconds(): float
+ {
+ return ($this->hours * SECONDS_PER_HOUR * MICROSECONDS_PER_SECOND) +
+ ($this->minutes * SECONDS_PER_MINUTE * MICROSECONDS_PER_SECOND) +
+ ($this->seconds * MICROSECONDS_PER_SECOND) +
+ ($this->nanoseconds / NANOSECONDS_PER_MICROSECOND);
+ }
+
+ /**
+ * Determines whether the instance represents a zero duration.
+ *
+ * @mutation-free
+ */
+ public function isZero(): bool
+ {
+ return $this->hours === 0 &&
+ $this->minutes === 0 &&
+ $this->seconds === 0 &&
+ $this->nanoseconds === 0;
+ }
+
+ /**
+ * Checks if the duration is positive, implying that all non-zero components are positive.
+ *
+ * Due to normalization, it is guaranteed that a positive time duration will
+ * have all of its parts (hours, minutes, seconds, nanoseconds) positive or
+ * equal to 0.
+ *
+ * Note that this method returns false if all parts are equal to 0.
+ *
+ * @mutation-free
+ */
+ public function isPositive(): bool
+ {
+ return $this->hours > 0 ||
+ $this->minutes > 0 ||
+ $this->seconds > 0 ||
+ $this->nanoseconds > 0;
+ }
+
+ /**
+ * Checks if the duration is negative, implying that all non-zero components are negative.
+ *
+ * Due to normalization, it is guaranteed that a negative time duration will
+ * have all of its parts (hours, minutes, seconds, nanoseconds) negative or
+ * equal to 0.
+ *
+ * Note that this method returns false if all parts are equal to 0.
+ *
+ * @mutation-free
+ */
+ public function isNegative(): bool
+ {
+ return $this->hours < 0 ||
+ $this->minutes < 0 ||
+ $this->seconds < 0 ||
+ $this->nanoseconds < 0;
+ }
+
+ /**
+ * Returns a new instance with the "hours" part changed to the specified
+ * value.
+ *
+ * Note that due to normalization, the actual value in the returned
+ * instance may differ, and this may affect other parts of the returned
+ * instance too.
+ *
+ * For example, `Duration::hours(2, 30)->withHours(-1)` is equivalent to
+ * `Duration::hours(-1, 30)` which normalizes to "-30 minute(s)".
+ *
+ * @mutation-free
+ */
+ public function withHours(int $hours): self
+ {
+ return self::fromParts(
+ $hours,
+ $this->minutes,
+ $this->seconds,
+ $this->nanoseconds,
+ );
+ }
+
+ /**
+ * Returns a new instance with the "minutes" part changed to the specified
+ * value.
+ *
+ * Note that due to normalization, the actual value in the returned
+ * instance may differ, and this may affect other parts of the returned
+ * instance too.
+ *
+ * For example, `Duration::minutes(2, 30)->withMinutes(-1)` is equivalent to
+ * `Duration::minutes(-1, 30)` which normalizes to "-30 second(s)".
+ *
+ * @mutation-free
+ */
+ public function withMinutes(int $minutes): self
+ {
+ return self::fromParts(
+ $this->hours,
+ $minutes,
+ $this->seconds,
+ $this->nanoseconds,
+ );
+ }
+
+ /**
+ * Returns a new instance with the "seconds" part changed to the specified
+ * value.
+ *
+ * Note that due to normalization, the actual value in the returned
+ * instance may differ, and this may affect other parts of the returned
+ * instance too.
+ *
+ * For example, `Duration::minutes(2, 30)->withSeconds(-30)` is equivalent
+ * to `Duration::minutes(2, -30)` which normalizes to "1 minute(s), 30 second(s)".
+ *
+ * @mutation-free
+ */
+ public function withSeconds(int $seconds): self
+ {
+ return self::fromParts(
+ $this->hours,
+ $this->minutes,
+ $seconds,
+ $this->nanoseconds,
+ );
+ }
+
+ /**
+ * Returns a new instance with the "nanoseconds" part changed to the specified
+ * value.
+ *
+ * Note that due to normalization, the actual value in the returned
+ * instance may differ, and this may affect other parts of the returned
+ * instance too.
+ *
+ * For example, `Duration::seconds(2)->withNanoseconds(-1)` is equivalent
+ * to `Duration::seconds(2, -1)` which normalizes to "1 second(s), 999999999 nanosecond(s)".
+ *
+ * @mutation-free
+ */
+ public function withNanoseconds(int $nanoseconds): self
+ {
+ return self::fromParts(
+ $this->hours,
+ $this->minutes,
+ $this->seconds,
+ $nanoseconds,
+ );
+ }
+
+ /**
+ * Implements a comparison between this duration and another, based on their duration.
+ *
+ * @param Duration $other
+ *
+ * @mutation-free
+ */
+ public function compare(mixed $other): Comparison\Order
+ {
+ if ($this->hours !== $other->hours) {
+ return Comparison\Order::from($this->hours <=> $other->hours);
+ }
+
+ if ($this->minutes !== $other->minutes) {
+ return Comparison\Order::from($this->minutes <=> $other->minutes);
+ }
+
+ if ($this->seconds !== $other->seconds) {
+ return Comparison\Order::from($this->seconds <=> $other->seconds);
+ }
+
+ return Comparison\Order::from($this->nanoseconds <=> $other->nanoseconds);
+ }
+
+ /**
+ * Evaluates whether this duration is equivalent to another, considering all time components.
+ *
+ * @param Duration $other
+ *
+ * @mutation-free
+ */
+ public function equals(mixed $other): bool
+ {
+ return $this->compare($other) === Comparison\Order::Equal;
+ }
+
+ /**
+ * Determines if this duration is shorter than another.
+ *
+ * @mutation-free
+ */
+ public function shorter(self $other): bool
+ {
+ return $this->compare($other) === Comparison\Order::Less;
+ }
+
+ /**
+ * Determines if this duration is shorter than, or equivalent to another.
+ *
+ * @mutation-free
+ */
+ public function shorterOrEqual(self $other): bool
+ {
+ return $this->compare($other) !== Comparison\Order::Greater;
+ }
+
+ /**
+ * Determines if this duration is longer than another.
+ *
+ * @mutation-free
+ */
+ public function longer(self $other): bool
+ {
+ return $this->compare($other) === Comparison\Order::Greater;
+ }
+
+ /**
+ * Determines if this duration is longer than, or equivalent to another.
+ *
+ * @mutation-free
+ */
+ public function longerOrEqual(self $other): bool
+ {
+ return $this->compare($other) !== Comparison\Order::Less;
+ }
+
+ /**
+ * Returns true if this instance represents a time duration longer than $a but
+ * shorter than $b, or vice-versa (shorter than $a but longer than $b), or if
+ * this instance is equal to $a and/or $b. Returns false if this instance is
+ * shorter/longer than both.
+ *
+ * @mutation-free
+ */
+ public function betweenInclusive(self $a, self $b): bool
+ {
+ $ca = $this->compare($a);
+ $cb = $this->compare($b);
+
+ return $ca === Comparison\Order::Equal || $ca !== $cb;
+ }
+
+ /**
+ * Returns true if this instance represents a time duration longer than $a but
+ * shorter than $b, or vice-versa (shorter than $a but longer than $b).
+ * Returns false if this instance is equal to $a and/or $b, or shorter/longer
+ * than both.
+ *
+ * @mutation-free
+ */
+ public function betweenExclusive(self $a, self $b): bool
+ {
+ $ca = $this->compare($a);
+ $cb = $this->compare($b);
+
+ return $ca !== Comparison\Order::Equal && $cb !== Comparison\Order::Equal && $ca !== $cb;
+ }
+
+ /**
+ * Returns a new instance, converting a positive/negative duration to the
+ * opposite (negative/positive) duration of equal length. The resulting
+ * instance has all parts equivalent to the current instance's parts
+ * multiplied by -1.
+ *
+ * @mutation-free
+ */
+ public function invert(): self
+ {
+ if ($this->isZero()) {
+ return $this;
+ }
+
+ return new self(
+ -$this->hours,
+ -$this->minutes,
+ -$this->seconds,
+ -$this->nanoseconds,
+ );
+ }
+
+ /**
+ * Returns a new instance representing the sum of this instance and the
+ * provided `$other` instance. Note that time duration can be negative, so
+ * the resulting instance is not guaranteed to be shorter/longer than either
+ * of the inputs.
+ *
+ * This operation is commutative: `$a->plus($b) === $b->plus($a)`
+ *
+ * @mutation-free
+ */
+ public function plus(self $other): self
+ {
+ if ($other->isZero()) {
+ return $this;
+ }
+
+ if ($this->isZero()) {
+ return $other;
+ }
+
+ return self::fromParts(
+ $this->hours + $other->hours,
+ $this->minutes + $other->minutes,
+ $this->seconds + $other->seconds,
+ $this->nanoseconds + $other->nanoseconds,
+ );
+ }
+
+ /**
+ * Returns a new instance representing the difference between this instance
+ * and the provided `$other` instance (i.e. `$other` subtracted from `$this`).
+ * Note that time duration can be negative, so the resulting instance is not
+ * guaranteed to be shorter/longer than either of the inputs.
+ *
+ * This operation is not commutative: `$a->minus($b) !== $b->minus($a)`
+ * But: `$a->minus($b) === $b->minus($a)->invert()`
+ *
+ * @mutation-free
+ */
+ public function minus(self $other): self
+ {
+ if ($other->isZero()) {
+ return $this;
+ }
+
+ if ($this->isZero()) {
+ return $other;
+ }
+
+ return self::fromParts(
+ $this->hours - $other->hours,
+ $this->minutes - $other->minutes,
+ $this->seconds - $other->seconds,
+ $this->nanoseconds - $other->nanoseconds,
+ );
+ }
+
+ /**
+ * Returns the time duration as string, useful e.g. for debugging. This is not
+ * meant to be a comprehensive way to format time durations for user-facing
+ * output.
+ *
+ * @param 0|positive-int $max_decimals
+ *
+ * @mutation-free
+ */
+ public function toString(int $max_decimals = 3): string
+ {
+ Psl\invariant($max_decimals >= 0, 'Expected a non-negative number of decimals.');
+
+ $decimal_part = '';
+ if ($max_decimals > 0) {
+ $decimal_part = (string)Math\abs($this->nanoseconds);
+ $decimal_part = Str\pad_left($decimal_part, 9, '0');
+ $decimal_part = Str\slice($decimal_part, 0, $max_decimals);
+ $decimal_part = Str\trim_right($decimal_part, '0');
+ }
+
+ if ($decimal_part !== '') {
+ $decimal_part = '.' . $decimal_part;
+ }
+
+ $sec_sign = $this->seconds < 0 || $this->nanoseconds < 0 ? '-' : '';
+ $sec = Math\abs($this->seconds);
+
+ $values = [
+ [((string) $this->hours), 'hour(s)'],
+ [((string) $this->minutes), 'minute(s)'],
+ [$sec_sign . $sec . $decimal_part, 'second(s)'],
+ ];
+
+ $end = Iter\count($values);
+ while ($end > 0 && $values[$end - 1][0] === '0') {
+ --$end;
+ }
+
+ $start = 0;
+ while ($start < $end && $values[$start][0] === '0') {
+ ++$start;
+ }
+
+ $output = [];
+ for ($i = $start; $i < $end; ++$i) {
+ $output[] = $values[$i][0] . ' ' . $values[$i][1];
+ }
+
+ return Iter\is_empty($output) ? '0 second(s)' : Str\join($output, ', ');
+ }
+
+ /**
+ * Returns a string representation of the time duration.
+ *
+ * @mutation-free
+ */
+ public function __toString(): string
+ {
+ return $this->toString();
+ }
+
+ /**
+ * Returns data which can be serialized by json_encode().
+ *
+ * @return array{hours: int, minutes: int, seconds: int, nanoseconds: int}
+ *
+ * @mutation-free
+ */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'hours' => $this->hours,
+ 'minutes' => $this->minutes,
+ 'seconds' => $this->seconds,
+ 'nanoseconds' => $this->nanoseconds,
+ ];
+ }
+}
diff --git a/src/Psl/DateTime/Era.php b/src/Psl/DateTime/Era.php
new file mode 100644
index 00000000..088eeeb4
--- /dev/null
+++ b/src/Psl/DateTime/Era.php
@@ -0,0 +1,40 @@
+ 0 ? self::AnnoDomini : self::BeforeChrist;
+ }
+
+ /**
+ * Toggles between AnnoDomini (AD) and BeforeChrist (BC).
+ *
+ * @return Era Returns BeforeChrist if the current instance is AnnoDomini, and vice versa.
+ */
+ public function toggle(): Era
+ {
+ return $this === self::AnnoDomini ? self::BeforeChrist : self::AnnoDomini;
+ }
+}
diff --git a/src/Psl/DateTime/Exception/ExceptionInterface.php b/src/Psl/DateTime/Exception/ExceptionInterface.php
new file mode 100644
index 00000000..ad1b04c6
--- /dev/null
+++ b/src/Psl/DateTime/Exception/ExceptionInterface.php
@@ -0,0 +1,11 @@
+value);
+ }
+
+ return $callback();
+ } finally {
+ if ($original !== null) {
+ date_default_timezone_set($original);
+ }
+ }
+}
diff --git a/src/Psl/DateTime/Meridiem.php b/src/Psl/DateTime/Meridiem.php
new file mode 100644
index 00000000..2697e120
--- /dev/null
+++ b/src/Psl/DateTime/Meridiem.php
@@ -0,0 +1,40 @@
+ $hour The hour in a 24-hour format.
+ *
+ * @return Meridiem Returns AnteMeridiem for hours less than 12, and PostMeridiem for hours 12 and above.
+ */
+ public static function fromHour(int $hour): Meridiem
+ {
+ return $hour < 12 ? self::AnteMeridiem : self::PostMeridiem;
+ }
+
+ /**
+ * Toggles between AnteMeridiem (AM) and PostMeridiem (PM).
+ *
+ * @return Meridiem Returns PostMeridiem if the current instance is AnteMeridiem, and vice versa.
+ */
+ public function toggle(): Meridiem
+ {
+ return $this === self::AnteMeridiem ? self::PostMeridiem : self::AnteMeridiem;
+ }
+}
diff --git a/src/Psl/DateTime/Month.php b/src/Psl/DateTime/Month.php
new file mode 100644
index 00000000..49db2c3c
--- /dev/null
+++ b/src/Psl/DateTime/Month.php
@@ -0,0 +1,139 @@
+ self::December,
+ self::February => self::January,
+ self::March => self::February,
+ self::April => self::March,
+ self::May => self::April,
+ self::June => self::May,
+ self::July => self::June,
+ self::August => self::July,
+ self::September => self::August,
+ self::October => self::September,
+ self::November => self::October,
+ self::December => self::November,
+ };
+ }
+
+ /**
+ * Returns the next month.
+ *
+ * This method calculates and returns the month succeeding the current instance of the Month enum.
+ *
+ * If the current instance is December, it wraps around and returns January.
+ *
+ * @return Month The next month.
+ */
+ public function getNext(): Month
+ {
+ return match ($this) {
+ self::January => self::February,
+ self::February => self::March,
+ self::March => self::April,
+ self::April => self::May,
+ self::May => self::June,
+ self::June => self::July,
+ self::July => self::August,
+ self::August => self::September,
+ self::September => self::October,
+ self::October => self::November,
+ self::November => self::December,
+ self::December => self::January,
+ };
+ }
+
+ /**
+ * Returns the number of days in the month for a given year.
+ *
+ * This method determines the number of days in the current month instance, considering whether the
+ * provided year is a leap year or not. It uses separate methods for leap years and non-leap years
+ * to get the appropriate day count.
+ *
+ * @param int $year The year for which the day count is needed.
+ *
+ * @return int<28, 31> The number of days in the month for the specified year.
+ */
+ public function getDaysForYear(int $year): int
+ {
+ if (namespace\is_leap_year($year)) {
+ return $this->getLeapYearDays();
+ }
+
+ return $this->getNonLeapYearDays();
+ }
+
+ /**
+ * Returns the number of days in the month for a non-leap year.
+ *
+ * This method provides the standard day count for the current month instance in a non-leap year.
+ *
+ * February returns 28, while April, June, September, and November return 30, and the rest return 31.
+ *
+ * @return int<28, 31> The number of days in the month for a non-leap year.
+ */
+ public function getNonLeapYearDays(): int
+ {
+ return match ($this) {
+ self::January, self::March, self::May, self::July, self::August, self::October, self::December => 31,
+ self::February => 28,
+ self::April, self::June, self::September, self::November => 30,
+ };
+ }
+
+ /**
+ * Returns the number of days in the month for a leap year.
+ *
+ * This method provides the day count for the current month instance in a leap year.
+ *
+ * February returns 29, while April, June, September, and November return 30, and the rest return 31.
+ *
+ * @return int<29, 31> The number of days in the month for a leap year.
+ */
+ public function getLeapYearDays(): int
+ {
+ return match ($this) {
+ self::January, self::March, self::May, self::July, self::August, self::October, self::December => 31,
+ self::February => 29,
+ self::April, self::June, self::September, self::November => 30,
+ };
+ }
+}
diff --git a/src/Psl/DateTime/TemporalConvenienceMethodsTrait.php b/src/Psl/DateTime/TemporalConvenienceMethodsTrait.php
new file mode 100644
index 00000000..74e683ec
--- /dev/null
+++ b/src/Psl/DateTime/TemporalConvenienceMethodsTrait.php
@@ -0,0 +1,251 @@
+getTimestamp()->toRaw();
+ $b = $other->getTimestamp()->toRaw();
+
+ return Comparison\Order::from($a[0] !== $b[0] ? $a[0] <=> $b[0] : $a[1] <=> $b[1]);
+ }
+
+ /**
+ * Checks if this temporal object represents the same time as the given one.
+ *
+ * @param TemporalInterface $other
+ *
+ * @mutation-free
+ */
+ public function equals(mixed $other): bool
+ {
+ return $this->isAtTheSameTime($other);
+ }
+
+ /**
+ * Checks if this temporal object represents the same time as the given one.
+ *
+ * Note: this method is an alias for {@see TemporalInterface::equals()}.
+ *
+ * @mutation-free
+ */
+ public function isAtTheSameTime(TemporalInterface $other): bool
+ {
+ return $this->compare($other) === Comparison\Order::Equal;
+ }
+
+ /**
+ * Checks if this temporal object is before the given one.
+ *
+ * @mutation-free
+ */
+ public function isBefore(TemporalInterface $other): bool
+ {
+ return $this->compare($other) < Comparison\Order::Less;
+ }
+
+ /**
+ * Checks if this temporal object is before or at the same time as the given one.
+ *
+ * @mutation-free
+ */
+ public function isBeforeOrAtTheSameTime(TemporalInterface $other): bool
+ {
+ return $this->compare($other) !== Comparison\Order::Greater;
+ }
+
+ /**
+ * Checks if this temporal object is after the given one.
+ *
+ * @mutation-free
+ */
+ public function isAfter(TemporalInterface $other): bool
+ {
+ return $this->compare($other) === Comparison\Order::Greater;
+ }
+
+ /**
+ * Checks if this temporal object is between the given times (inclusive).
+ *
+ * @mutation-free
+ */
+ public function isAfterOrAtTheSameTime(TemporalInterface $other): bool
+ {
+ return $this->compare($other) !== Comparison\Order::Less;
+ }
+
+ /**
+ * Checks if this temporal object is between the given times (exclusive).
+ *
+ * @mutation-free
+ */
+ public function isBetweenTimeInclusive(TemporalInterface $a, TemporalInterface $b): bool
+ {
+ $ca = $this->compare($a);
+ $cb = $this->compare($b);
+
+ return $ca === Comparison\Order::Equal || $ca !== $cb;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @mutation-free
+ */
+ public function isBetweenTimeExclusive(TemporalInterface $a, TemporalInterface $b): bool
+ {
+ $ca = $this->compare($a);
+ $cb = $this->compare($b);
+
+ return $ca !== Comparison\Order::Equal && $cb !== Comparison\Order::Equal && $ca !== $cb;
+ }
+
+ /**
+ * Adds the specified hours to this temporal object, returning a new instance with the added hours.
+ *
+ * @throws Exception\UnderflowException If adding the hours results in an arithmetic underflow.
+ * @throws Exception\OverflowException If adding the hours results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function plusHours(int $hours): static
+ {
+ return $this->plus(Duration::hours($hours));
+ }
+
+ /**
+ * Adds the specified minutes to this temporal object, returning a new instance with the added minutes.
+ *
+ * @throws Exception\UnderflowException If adding the minutes results in an arithmetic underflow.
+ * @throws Exception\OverflowException If adding the minutes results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function plusMinutes(int $minutes): static
+ {
+ return $this->plus(Duration::minutes($minutes));
+ }
+
+ /**
+ * Adds the specified seconds to this temporal object, returning a new instance with the added seconds.
+ *
+ * @throws Exception\UnderflowException If adding the seconds results in an arithmetic underflow.
+ * @throws Exception\OverflowException If adding the seconds results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function plusSeconds(int $seconds): static
+ {
+ return $this->plus(Duration::seconds($seconds));
+ }
+
+ /**
+ * Adds the specified nanoseconds to this temporal object, returning a new instance with the added nanoseconds.
+ *
+ * @throws Exception\UnderflowException If adding the nanoseconds results in an arithmetic underflow.
+ * @throws Exception\OverflowException If adding the nanoseconds results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function plusNanoseconds(int $nanoseconds): static
+ {
+ return $this->plus(Duration::nanoseconds($nanoseconds));
+ }
+
+ /**
+ * Subtracts the specified hours from this temporal object, returning a new instance with the subtracted hours.
+ *
+ * @throws Exception\UnderflowException If subtracting the hours results in an arithmetic underflow.
+ * @throws Exception\OverflowException If subtracting the hours results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function minusHours(int $hours): static
+ {
+ return $this->minus(Duration::hours($hours));
+ }
+
+ /**
+ * Subtracts the specified minutes from this temporal object, returning a new instance with the subtracted minutes.
+ *
+ * @throws Exception\UnderflowException If subtracting the minutes results in an arithmetic underflow.
+ * @throws Exception\OverflowException If subtracting the minutes results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function minusMinutes(int $minutes): static
+ {
+ return $this->minus(Duration::minutes($minutes));
+ }
+
+ /**
+ * Subtracts the specified seconds from this temporal object, returning a new instance with the subtracted seconds.
+ *
+ * @throws Exception\UnderflowException If subtracting the seconds results in an arithmetic underflow.
+ * @throws Exception\OverflowException If subtracting the seconds results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function minusSeconds(int $seconds): static
+ {
+ return $this->minus(Duration::seconds($seconds));
+ }
+
+ /**
+ * Subtracts the specified nanoseconds from this temporal object, returning a new instance with the subtracted nanoseconds.
+ *
+ * @throws Exception\UnderflowException If subtracting the nanoseconds results in an arithmetic underflow.
+ * @throws Exception\OverflowException If subtracting the nanoseconds results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function minusNanoseconds(int $nanoseconds): static
+ {
+ return $this->minus(Duration::nanoseconds($nanoseconds));
+ }
+
+ /**
+ * Calculates the duration between this temporal object and the given one.
+ *
+ * @param TemporalInterface $other The temporal object to calculate the duration to.
+ *
+ * @return Duration The duration between the two temporal objects.
+ *
+ * @mutation-free
+ */
+ public function since(TemporalInterface $other): Duration
+ {
+ $a = $this->getTimestamp()->toRaw();
+ $b = $other->getTimestamp()->toRaw();
+
+ return Duration::fromParts(0, 0, $a[0] - $b[0], $a[1] - $b[1]);
+ }
+
+ /**
+ * Converts the current temporal object to a new {@see DateTimeInterface} instance in a different timezone.
+ *
+ * @param Timezone $timezone The target timezone for the conversion.
+ *
+ * @mutation-free
+ */
+ public function convertToTimezone(Timezone $timezone): DateTimeInterface
+ {
+ return DateTime::fromTimestamp($this->getTimestamp(), $timezone);
+ }
+}
diff --git a/src/Psl/DateTime/TemporalInterface.php b/src/Psl/DateTime/TemporalInterface.php
new file mode 100644
index 00000000..755cdab4
--- /dev/null
+++ b/src/Psl/DateTime/TemporalInterface.php
@@ -0,0 +1,205 @@
+
+ * @extends Equable
+ */
+interface TemporalInterface extends Comparable, Equable, JsonSerializable
+{
+ /**
+ * Returns the timestamp representation of this temporal object.
+ *
+ * @mutation-free
+ */
+ public function getTimestamp(): Timestamp;
+
+ /**
+ * Checks if this temporal object represents the same time as the given one.
+ *
+ * Note: this method is an alias for {@see TemporalInterface::isAtTheSameTime()}.
+ *
+ * @param TemporalInterface $other
+ *
+ * @mutation-free
+ */
+ public function equals(mixed $other): bool;
+
+ /**
+ * Checks if this temporal object represents the same time as the given one.
+ *
+ * @mutation-free
+ */
+ public function isAtTheSameTime(TemporalInterface $other): bool;
+
+ /**
+ * Checks if this temporal object is before the given one.
+ *
+ * @mutation-free
+ */
+ public function isBefore(TemporalInterface $other): bool;
+
+ /**
+ * Checks if this temporal object is before or at the same time as the given one.
+ *
+ * @mutation-free
+ */
+ public function isBeforeOrAtTheSameTime(TemporalInterface $other): bool;
+
+ /**
+ * Checks if this temporal object is after the given one.
+ *
+ * @mutation-free
+ */
+ public function isAfter(TemporalInterface $other): bool;
+
+ /**
+ * Checks if this temporal object is after or at the same time as the given one.
+ *
+ * @mutation-free
+ */
+ public function isAfterOrAtTheSameTime(TemporalInterface $other): bool;
+
+ /**
+ * Checks if this temporal object is between the given times (inclusive).
+ *
+ * @mutation-free
+ */
+ public function isBetweenTimeInclusive(TemporalInterface $a, TemporalInterface $b): bool;
+
+ /**
+ * Checks if this temporal object is between the given times (exclusive).
+ *
+ * @mutation-free
+ */
+ public function isBetweenTimeExclusive(TemporalInterface $a, TemporalInterface $b): bool;
+
+ /**
+ * Adds the specified duration to this temporal object, returning a new instance with the added duration.
+ *
+ * @throws Exception\UnderflowException If adding the duration results in an arithmetic underflow.
+ * @throws Exception\OverflowException If adding the duration results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function plus(Duration $duration): static;
+
+ /**
+ * Subtracts the specified duration from this temporal object, returning a new instance with the subtracted duration.
+ *
+ * @throws Exception\UnderflowException If subtracting the duration results in an arithmetic underflow.
+ * @throws Exception\OverflowException If subtracting the duration results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function minus(Duration $duration): static;
+
+ /**
+ * Adds the specified hours to this temporal object, returning a new instance with the added hours.
+ *
+ * @throws Exception\UnderflowException If adding the hours results in an arithmetic underflow.
+ * @throws Exception\OverflowException If adding the hours results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function plusHours(int $hours): static;
+
+ /**
+ * Adds the specified minutes to this temporal object, returning a new instance with the added minutes.
+ *
+ * @throws Exception\UnderflowException If adding the minutes results in an arithmetic underflow.
+ * @throws Exception\OverflowException If adding the minutes results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function plusMinutes(int $minutes): static;
+
+ /**
+ * Adds the specified seconds to this temporal object, returning a new instance with the added seconds.
+ *
+ * @throws Exception\UnderflowException If adding the seconds results in an arithmetic underflow.
+ * @throws Exception\OverflowException If adding the seconds results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function plusSeconds(int $seconds): static;
+
+ /**
+ * Adds the specified nanoseconds to this temporal object, returning a new instance with the added nanoseconds.
+ *
+ * @throws Exception\UnderflowException If adding the nanoseconds results in an arithmetic underflow.
+ * @throws Exception\OverflowException If adding the nanoseconds results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function plusNanoseconds(int $nanoseconds): static;
+
+ /**
+ * Subtracts the specified hours from this temporal object, returning a new instance with the subtracted hours.
+ *
+ * @throws Exception\UnderflowException If subtracting the hours results in an arithmetic underflow.
+ * @throws Exception\OverflowException If subtracting the hours results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function minusHours(int $hours): static;
+
+ /**
+ * Subtracts the specified minutes from this temporal object, returning a new instance with the subtracted minutes.
+ *
+ * @throws Exception\UnderflowException If subtracting the minutes results in an arithmetic underflow.
+ * @throws Exception\OverflowException If subtracting the minutes results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function minusMinutes(int $minutes): static;
+
+ /**
+ * Subtracts the specified seconds from this temporal object, returning a new instance with the subtracted seconds.
+ *
+ * @throws Exception\UnderflowException If subtracting the seconds results in an arithmetic underflow.
+ * @throws Exception\OverflowException If subtracting the seconds results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function minusSeconds(int $seconds): static;
+
+ /**
+ * Subtracts the specified nanoseconds from this temporal object, returning a new instance with the subtracted nanoseconds.
+ *
+ * @throws Exception\UnderflowException If subtracting the nanoseconds results in an arithmetic underflow.
+ * @throws Exception\OverflowException If subtracting the nanoseconds results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function minusNanoseconds(int $nanoseconds): static;
+
+ /**
+ * Calculates the duration between this temporal object and the given one.
+ *
+ * @param TemporalInterface $other The temporal object to calculate the duration to.
+ *
+ * @return Duration The duration between the two temporal objects.
+ *
+ * @mutation-free
+ */
+ public function since(TemporalInterface $other): Duration;
+
+ /**
+ * Converts the current temporal object to a new {@see DateTimeInterface} instance in a different timezone.
+ *
+ * @param Timezone $timezone The target timezone for the conversion.
+ *
+ * @mutation-free
+ */
+ public function convertToTimezone(Timezone $timezone): DateTimeInterface;
+}
diff --git a/src/Psl/DateTime/Timestamp.php b/src/Psl/DateTime/Timestamp.php
new file mode 100644
index 00000000..5f6a582f
--- /dev/null
+++ b/src/Psl/DateTime/Timestamp.php
@@ -0,0 +1,266 @@
+ $nanoseconds
+ */
+ private function __construct(
+ private readonly int $seconds,
+ private readonly int $nanoseconds,
+ ) {
+ }
+
+ /**
+ * Creates a new instance for the current moment.
+ *
+ * Returns the current time with precision up to nanoseconds, adjusted to maintain accuracy.
+ *
+ * @return self The current timestamp.
+ *
+ * @pure
+ */
+ public static function now(): self
+ {
+ $hrTime = hrtime();
+
+ // Calculate the offset if not already calculated
+ if (self::$offset === null) {
+ $now = time();
+ $nowHrTime = hrtime();
+
+ self::$offset = [
+ 'seconds' => $now - $nowHrTime[0],
+ 'nanoseconds' => $nowHrTime[1],
+ ];
+ }
+
+ // Add the offset to the current hrtime to get the precise time
+ $seconds = $hrTime[0] + self::$offset['seconds'];
+ $nanoseconds = $hrTime[1] + self::$offset['nanoseconds'];
+
+ // Normalize nanoseconds
+ $seconds += (int)($nanoseconds / NANOSECONDS_PER_SECOND);
+ $nanoseconds %= NANOSECONDS_PER_SECOND;
+
+ return self::fromRaw($seconds, $nanoseconds);
+ }
+
+ /**
+ * Creates a timestamp from seconds and nanoseconds since the epoch.
+ *
+ * Normalizes so nanoseconds are within 0-999999999. For instance:
+ * - `fromRaw(42, -100)` becomes (41, 999999900).
+ * - `fromRaw(-42, -100)` becomes (-43, 999999900).
+ * - `fromRaw(42, 1000000100)` becomes (43, 100).
+ *
+ * @param int $seconds Seconds since the epoch.
+ * @param int $nanoseconds Additional nanoseconds to adjust by.
+ *
+ * @throws Exception\OverflowException
+ * @throws Exception\UnderflowException
+ *
+ * @pure
+ */
+ public static function fromRaw(int $seconds, int $nanoseconds = 0): Timestamp
+ {
+ // Check for potential overflow or underflow before doing any operation
+ if ($seconds === Math\INT64_MAX && $nanoseconds > 0) {
+ throw new Exception\OverflowException("Adding nanoseconds would cause an overflow.");
+ }
+ if ($seconds === Math\INT64_MIN && $nanoseconds < 0) {
+ throw new Exception\UnderflowException("Subtracting nanoseconds would cause an underflow.");
+ }
+
+ $secondsAdjustment = Math\div($nanoseconds, NANOSECONDS_PER_SECOND);
+ $finalSeconds = $seconds + $secondsAdjustment;
+
+ $normalizedNanoseconds = $nanoseconds % NANOSECONDS_PER_SECOND;
+ if ($normalizedNanoseconds < 0) {
+ --$finalSeconds;
+ $normalizedNanoseconds += NANOSECONDS_PER_SECOND;
+ }
+
+ return new self($finalSeconds, $normalizedNanoseconds);
+ }
+
+
+ /**
+ * Parses a date/time string into an instance, considering the provided timezone.
+ *
+ * If the `$raw_string` includes a timezone, it overrides the `$timezone` argument. The
+ * `$relative_to` argument is for parsing relative time strings, defaulting to the current
+ * time if not specified.
+ *
+ * @param ?Timezone $timezone The timezone context for parsing. Defaults to the system's timezone.
+ * @param ?TemporalInterface $relative_to Context for relative time strings.
+ *
+ * @throws Exception\InvalidArgumentException If parsing fails or the format is invalid.
+ *
+ * @see https://www.php.net/manual/en/datetime.formats.php
+ *
+ * @pure
+ */
+ public static function parse(string $raw_string, ?Timezone $timezone = null, ?TemporalInterface $relative_to = null): self
+ {
+ $timezone ??= Timezone::default();
+
+ return Internal\zone_override($timezone, static function () use ($raw_string, $relative_to): self {
+ if ($relative_to !== null) {
+ $relative_to = $relative_to->getTimestamp()->getSeconds();
+ }
+
+ $raw = strtotime($raw_string, $relative_to);
+ if ($raw === false) {
+ throw new Exception\InvalidArgumentException(
+ "Failed to parse the provided date/time string: '$raw_string'",
+ );
+ }
+
+ return self::fromRaw($raw);
+ });
+ }
+
+ /**
+ * Returns this Timestamp instance itself, as it already represents a timestamp.
+ *
+ * @mutation-free
+ */
+ public function getTimestamp(): self
+ {
+ return $this;
+ }
+
+ /**
+ * Returns the raw seconds and nanoseconds of the timestamp as an array.
+ *
+ * @return array{int, int<0, 999999999>}
+ *
+ * @mutation-free
+ */
+ public function toRaw(): array
+ {
+ return [$this->seconds, $this->nanoseconds];
+ }
+
+ /**
+ * Returns the number of seconds since the Unix epoch represented by this timestamp.
+ *
+ * @return int Seconds since the epoch. Can be negative for times before the epoch.
+ *
+ * @mutation-free
+ */
+ public function getSeconds(): int
+ {
+ return $this->seconds;
+ }
+
+ /**
+ * Returns the nanoseconds part of this timestamp.
+ *
+ * @return int<0, 999999999> The nanoseconds part, ranging from 0 to 999999999.
+ *
+ * @mutation-free
+ */
+ public function getNanoseconds(): int
+ {
+ return $this->nanoseconds;
+ }
+
+ /**
+ * Adds the specified duration to this timestamp object, returning a new instance with the added duration.
+ *
+ * @throws Exception\UnderflowException If adding the duration results in an arithmetic underflow.
+ * @throws Exception\OverflowException If adding the duration results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function plus(Duration $duration): static
+ {
+ [$h, $m, $s, $ns] = $duration->getParts();
+ $totalSeconds = SECONDS_PER_MINUTE * $m + SECONDS_PER_HOUR * $h + $s;
+ $newSeconds = $this->seconds + $totalSeconds;
+ $newNanoseconds = $this->nanoseconds + $ns;
+
+ // No manual normalization required here due to fromRaw handling it
+ return self::fromRaw($newSeconds, $newNanoseconds);
+ }
+
+ /**
+ * Subtracts the specified duration from this timestamp object, returning a new instance with the subtracted duration.
+ *
+ * @throws Exception\UnderflowException If subtracting the duration results in an arithmetic underflow.
+ * @throws Exception\OverflowException If subtracting the duration results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function minus(Duration $duration): static
+ {
+ [$h, $m, $s, $ns] = $duration->getParts();
+ $totalSeconds = SECONDS_PER_MINUTE * $m + SECONDS_PER_HOUR * $h + $s;
+ $newSeconds = $this->seconds - $totalSeconds;
+ $newNanoseconds = $this->nanoseconds - $ns;
+
+ // No manual normalization required here due to fromRaw handling it
+ return self::fromRaw($newSeconds, $newNanoseconds);
+ }
+
+ /**
+ * Formats the timestamp according to the given format and timezone.
+ *
+ * The format can be a predefined {@see DateFormat} case, a date-time format string, or `null` for default formatting.
+ *
+ * The timezone is required to accurately represent the timestamp in the desired locale.
+ *
+ * @mutation-free
+ */
+ public function format(Timezone $timezone, null|DateFormat|string $format = null, ?Locale\Locale $locale = null): string
+ {
+ return Internal\zone_override($timezone, function () use ($timezone, $locale, $format): string {
+ $obj = new NativeDateTime();
+ $obj->setTimestamp($this->getSeconds());
+ $obj->setTimezone(new NativeDateTimeZone($timezone->value));
+
+ if ($format instanceof DateFormat) {
+ $format = $format->value;
+ }
+
+ return IntlDateFormatter::formatObject($obj, $format, $locale->value);
+ });
+ }
+
+ public function jsonSerialize(): array
+ {
+ return [
+ 'seconds' => $this->seconds,
+ 'nanoseconds' => $this->nanoseconds,
+ ];
+ }
+}
diff --git a/src/Psl/DateTime/Timezone.php b/src/Psl/DateTime/Timezone.php
new file mode 100644
index 00000000..1a224ab0
--- /dev/null
+++ b/src/Psl/DateTime/Timezone.php
@@ -0,0 +1,546 @@
+getTimestamp()->getSeconds()));
+ });
+ }
+
+ /**
+ * Retrieves location information associated with this time zone.
+ *
+ * For time zones corresponding to a specific location, this method provides geographical
+ * information such as the country code, latitude, and longitude. This can be useful for
+ * applications needing to display location-based data or perform calculations based on
+ * geographical positions.
+ *
+ * @return TimezoneLocation|null Location information for the time zone, or null if the time zone does not
+ * correspond to a specific geographical location or if location data is unavailable.
+ *
+ * @mutation-free
+ */
+ public function getLocation(): ?TimezoneLocation
+ {
+ $tz = new DateTimeZone($this->value);
+ $location = $tz->getLocation();
+ if (!$location || '??' === $location['country_code']) {
+ return null;
+ }
+
+ return new TimezoneLocation($location['country_code'], $location['latitude'], $location['longitude']);
+ }
+}
diff --git a/src/Psl/DateTime/TimezoneLocation.php b/src/Psl/DateTime/TimezoneLocation.php
new file mode 100644
index 00000000..24ef0f68
--- /dev/null
+++ b/src/Psl/DateTime/TimezoneLocation.php
@@ -0,0 +1,49 @@
+ $this->countryCode,
+ 'latitude' => $this->latitude,
+ 'longitude' => $this->longitude,
+ ];
+ }
+}
diff --git a/src/Psl/DateTime/Weekday.php b/src/Psl/DateTime/Weekday.php
new file mode 100644
index 00000000..cda38f8e
--- /dev/null
+++ b/src/Psl/DateTime/Weekday.php
@@ -0,0 +1,64 @@
+ self::Sunday,
+ self::Tuesday => self::Monday,
+ self::Wednesday => self::Tuesday,
+ self::Thursday => self::Wednesday,
+ self::Friday => self::Thursday,
+ self::Saturday => self::Friday,
+ self::Sunday => self::Saturday,
+ };
+ }
+
+ /**
+ * Returns the next weekday.
+ *
+ * If the current instance is Sunday, it wraps around and returns Monday.
+ *
+ * @return Weekday The next weekday.
+ */
+ public function getNext(): Weekday
+ {
+ return match ($this) {
+ self::Monday => self::Tuesday,
+ self::Tuesday => self::Wednesday,
+ self::Wednesday => self::Thursday,
+ self::Thursday => self::Friday,
+ self::Friday => self::Saturday,
+ self::Saturday => self::Sunday,
+ self::Sunday => self::Monday,
+ };
+ }
+}
diff --git a/src/Psl/DateTime/constants.php b/src/Psl/DateTime/constants.php
new file mode 100644
index 00000000..9a82aef4
--- /dev/null
+++ b/src/Psl/DateTime/constants.php
@@ -0,0 +1,117 @@
+readHandle->read($max_bytes, $timeout);
}
diff --git a/src/Psl/File/ReadWriteHandle.php b/src/Psl/File/ReadWriteHandle.php
index 400e3f1d..3d604b77 100644
--- a/src/Psl/File/ReadWriteHandle.php
+++ b/src/Psl/File/ReadWriteHandle.php
@@ -4,6 +4,7 @@
namespace Psl\File;
+use Psl\DateTime\Duration;
use Psl\Filesystem;
use Psl\IO;
use Psl\Str;
@@ -78,7 +79,7 @@ public function tryRead(?int $max_bytes = null): string
/**
* {@inheritDoc}
*/
- public function read(?int $max_bytes = null, ?float $timeout = null): string
+ public function read(?int $max_bytes = null, null|Duration|float $timeout = null): string
{
return $this->readWriteHandle->read($max_bytes, $timeout);
}
@@ -94,7 +95,7 @@ public function tryWrite(string $bytes): int
/**
* {@inheritDoc}
*/
- public function write(string $bytes, ?float $timeout = null): int
+ public function write(string $bytes, null|Duration|float $timeout = null): int
{
return $this->readWriteHandle->write($bytes, $timeout);
}
diff --git a/src/Psl/File/WriteHandle.php b/src/Psl/File/WriteHandle.php
index 28664cfc..2c0e2ef3 100644
--- a/src/Psl/File/WriteHandle.php
+++ b/src/Psl/File/WriteHandle.php
@@ -4,6 +4,7 @@
namespace Psl\File;
+use Psl\DateTime\Duration;
use Psl\Filesystem;
use Psl\IO;
use Psl\Str;
@@ -66,7 +67,7 @@ public function tryWrite(string $bytes): int
/**
* {@inheritDoc}
*/
- public function write(string $bytes, ?float $timeout = null): int
+ public function write(string $bytes, null|Duration|float $timeout = null): int
{
return $this->writeHandle->write($bytes, $timeout);
}
diff --git a/src/Psl/IO/CloseReadStreamHandle.php b/src/Psl/IO/CloseReadStreamHandle.php
index 3bc288c6..0cb91176 100644
--- a/src/Psl/IO/CloseReadStreamHandle.php
+++ b/src/Psl/IO/CloseReadStreamHandle.php
@@ -4,6 +4,7 @@
namespace Psl\IO;
+use Psl\DateTime\Duration;
use Psl\IO;
/**
@@ -34,7 +35,7 @@ public function tryRead(?int $max_bytes = null): string
/**
* {@inheritDoc}
*/
- public function read(?int $max_bytes = null, ?float $timeout = null): string
+ public function read(?int $max_bytes = null, null|Duration|float $timeout = null): string
{
return $this->handle->read($max_bytes, $timeout);
}
diff --git a/src/Psl/IO/CloseReadWriteStreamHandle.php b/src/Psl/IO/CloseReadWriteStreamHandle.php
index 5ffce07c..7c6d4121 100644
--- a/src/Psl/IO/CloseReadWriteStreamHandle.php
+++ b/src/Psl/IO/CloseReadWriteStreamHandle.php
@@ -4,6 +4,7 @@
namespace Psl\IO;
+use Psl\DateTime\Duration;
use Psl\IO;
/**
@@ -35,7 +36,7 @@ public function tryRead(?int $max_bytes = null): string
/**
* {@inheritDoc}
*/
- public function read(?int $max_bytes = null, ?float $timeout = null): string
+ public function read(?int $max_bytes = null, null|Duration|float $timeout = null): string
{
return $this->handle->read($max_bytes, $timeout);
}
@@ -51,7 +52,7 @@ public function tryWrite(string $bytes): int
/**
* {@inheritDoc}
*/
- public function write(string $bytes, ?float $timeout = null): int
+ public function write(string $bytes, null|Duration|float $timeout = null): int
{
return $this->handle->write($bytes, $timeout);
}
diff --git a/src/Psl/IO/CloseSeekReadStreamHandle.php b/src/Psl/IO/CloseSeekReadStreamHandle.php
index ced72ded..dcd9868f 100644
--- a/src/Psl/IO/CloseSeekReadStreamHandle.php
+++ b/src/Psl/IO/CloseSeekReadStreamHandle.php
@@ -4,6 +4,7 @@
namespace Psl\IO;
+use Psl\DateTime\Duration;
use Psl\IO;
/**
@@ -34,7 +35,7 @@ public function tryRead(?int $max_bytes = null): string
/**
* {@inheritDoc}
*/
- public function read(?int $max_bytes = null, ?float $timeout = null): string
+ public function read(?int $max_bytes = null, null|Duration|float $timeout = null): string
{
return $this->handle->read($max_bytes, $timeout);
}
diff --git a/src/Psl/IO/CloseSeekReadWriteStreamHandle.php b/src/Psl/IO/CloseSeekReadWriteStreamHandle.php
index ef665ce7..1bdeb0dc 100644
--- a/src/Psl/IO/CloseSeekReadWriteStreamHandle.php
+++ b/src/Psl/IO/CloseSeekReadWriteStreamHandle.php
@@ -4,6 +4,7 @@
namespace Psl\IO;
+use Psl\DateTime\Duration;
use Psl\IO;
/**
@@ -35,7 +36,7 @@ public function tryRead(?int $max_bytes = null): string
/**
* {@inheritDoc}
*/
- public function read(?int $max_bytes = null, ?float $timeout = null): string
+ public function read(?int $max_bytes = null, null|Duration|float $timeout = null): string
{
return $this->handle->read($max_bytes, $timeout);
}
@@ -51,7 +52,7 @@ public function tryWrite(string $bytes): int
/**
* {@inheritDoc}
*/
- public function write(string $bytes, ?float $timeout = null): int
+ public function write(string $bytes, null|Duration|float $timeout = null): int
{
return $this->handle->write($bytes, $timeout);
}
diff --git a/src/Psl/IO/CloseSeekWriteStreamHandle.php b/src/Psl/IO/CloseSeekWriteStreamHandle.php
index dead4b94..244dfb94 100644
--- a/src/Psl/IO/CloseSeekWriteStreamHandle.php
+++ b/src/Psl/IO/CloseSeekWriteStreamHandle.php
@@ -4,6 +4,7 @@
namespace Psl\IO;
+use Psl\DateTime\Duration;
use Psl\IO;
/**
@@ -34,7 +35,7 @@ public function tryWrite(string $bytes): int
/**
* {@inheritDoc}
*/
- public function write(string $bytes, ?float $timeout = null): int
+ public function write(string $bytes, null|Duration|float $timeout = null): int
{
return $this->handle->write($bytes, $timeout);
}
diff --git a/src/Psl/IO/CloseWriteStreamHandle.php b/src/Psl/IO/CloseWriteStreamHandle.php
index ff86af6e..58dea355 100644
--- a/src/Psl/IO/CloseWriteStreamHandle.php
+++ b/src/Psl/IO/CloseWriteStreamHandle.php
@@ -4,6 +4,7 @@
namespace Psl\IO;
+use Psl\DateTime\Duration;
use Psl\IO;
/**
@@ -34,7 +35,7 @@ public function tryWrite(string $bytes): int
/**
* {@inheritDoc}
*/
- public function write(string $bytes, ?float $timeout = null): int
+ public function write(string $bytes, null|Duration|float $timeout = null): int
{
return $this->handle->write($bytes, $timeout);
}
diff --git a/src/Psl/IO/Internal/OptionalIncrementalTimeout.php b/src/Psl/IO/Internal/OptionalIncrementalTimeout.php
index 685f2199..1b37777e 100644
--- a/src/Psl/IO/Internal/OptionalIncrementalTimeout.php
+++ b/src/Psl/IO/Internal/OptionalIncrementalTimeout.php
@@ -5,6 +5,7 @@
namespace Psl\IO\Internal;
use Closure;
+use Psl\DateTime\Duration;
use function microtime;
@@ -24,8 +25,12 @@ final class OptionalIncrementalTimeout
/**
* @param (Closure(): ?int) $handler
*/
- public function __construct(?float $timeout, Closure $handler)
+ public function __construct(null|Duration|float $timeout, Closure $handler)
{
+ if ($timeout instanceof Duration) {
+ $timeout = $timeout->getTotalSeconds();
+ }
+
$this->handler = $handler;
if ($timeout === null) {
$this->end = null;
diff --git a/src/Psl/IO/Internal/ResourceHandle.php b/src/Psl/IO/Internal/ResourceHandle.php
index ca908ec7..551bd12d 100644
--- a/src/Psl/IO/Internal/ResourceHandle.php
+++ b/src/Psl/IO/Internal/ResourceHandle.php
@@ -6,6 +6,7 @@
use Psl;
use Psl\Async;
+use Psl\DateTime\Duration;
use Psl\IO;
use Psl\IO\Exception;
use Psl\Type;
@@ -47,7 +48,7 @@ class ResourceHandle implements IO\CloseSeekReadWriteStreamHandleInterface
protected mixed $stream;
/**
- * @var null|Async\Sequence, null|float}, string>
+ * @var null|Async\Sequence, null|Duration|float}, string>
*/
private ?Async\Sequence $readSequence = null;
@@ -59,7 +60,7 @@ class ResourceHandle implements IO\CloseSeekReadWriteStreamHandleInterface
private string $readWatcher = 'invalid';
/**
- * @var null|Async\Sequence>
+ * @var null|Async\Sequence>
*/
private ?Async\Sequence $writeSequence = null;
@@ -96,7 +97,7 @@ public function __construct(mixed $stream, bool $read, bool $write, bool $seek,
});
$this->readSequence = new Async\Sequence(
/**
- * @param array{null|int<1, max>, null|float} $input
+ * @param array{null|int<1, max>, null|Duration|float} $input
*/
function (array $input) use ($blocks): string {
[$max_bytes, $timeout] = $input;
@@ -109,6 +110,10 @@ function (array $input) use ($blocks): string {
EventLoop::enable($this->readWatcher);
$delay_watcher = null;
if (null !== $timeout) {
+ if ($timeout instanceof Duration) {
+ $timeout = $timeout->getTotalSeconds();
+ }
+
$timeout = max($timeout, 0.0);
$delay_watcher = EventLoop::delay(
$timeout,
@@ -149,7 +154,7 @@ function (array $input) use ($blocks): string {
});
$this->writeSequence = new Async\Sequence(
/**
- * @param array{string, null|float} $input
+ * @param array{string, null|float|Duration} $input
*
* @return int<0, max>
*/
@@ -165,6 +170,10 @@ function (array $input) use ($blocks): int {
EventLoop::enable($this->writeWatcher);
$delay_watcher = null;
if (null !== $timeout) {
+ if ($timeout instanceof Duration) {
+ $timeout = $timeout->getTotalSeconds();
+ }
+
$timeout = max($timeout, 0.0);
$delay_watcher = EventLoop::delay(
$timeout,
@@ -194,7 +203,7 @@ function (array $input) use ($blocks): int {
/**
* {@inheritDoc}
*/
- public function write(string $bytes, ?float $timeout = null): int
+ public function write(string $bytes, null|Duration|float $timeout = null): int
{
Psl\invariant($this->writeSequence !== null, 'The resource handle is not writable.');
@@ -257,7 +266,7 @@ public function tell(): int
/**
* {@inheritDoc}
*/
- public function read(?int $max_bytes = null, ?float $timeout = null): string
+ public function read(?int $max_bytes = null, null|Duration|float $timeout = null): string
{
Psl\invariant($this->readSequence !== null, 'The resource handle is not readable.');
diff --git a/src/Psl/IO/MemoryHandle.php b/src/Psl/IO/MemoryHandle.php
index 4aa4c7c5..d72540cd 100644
--- a/src/Psl/IO/MemoryHandle.php
+++ b/src/Psl/IO/MemoryHandle.php
@@ -4,6 +4,7 @@
namespace Psl\IO;
+use Psl\DateTime\Duration;
use Psl\Math;
use function str_repeat;
@@ -66,7 +67,7 @@ public function tryRead(?int $max_bytes = null): string
*
* @return string the read data on success, or an empty string if the end of file is reached.
*/
- public function read(?int $max_bytes = null, ?float $timeout = null): string
+ public function read(?int $max_bytes = null, null|Duration|float $timeout = null): string
{
return $this->tryRead($max_bytes);
}
@@ -94,7 +95,7 @@ public function tell(): int
/**
* {@inheritDoc}
*/
- public function tryWrite(string $bytes, ?float $timeout = null): int
+ public function tryWrite(string $bytes, null|Duration|float $timeout = null): int
{
$this->assertHandleIsOpen();
$length = strlen($this->buffer);
@@ -119,7 +120,7 @@ public function tryWrite(string $bytes, ?float $timeout = null): int
/**
* {@inheritDoc}
*/
- public function write(string $bytes, ?float $timeout = null): int
+ public function write(string $bytes, null|Duration|float $timeout = null): int
{
return $this->tryWrite($bytes);
}
diff --git a/src/Psl/IO/ReadHandleConvenienceMethodsTrait.php b/src/Psl/IO/ReadHandleConvenienceMethodsTrait.php
index ad3d7d21..2ef4c721 100644
--- a/src/Psl/IO/ReadHandleConvenienceMethodsTrait.php
+++ b/src/Psl/IO/ReadHandleConvenienceMethodsTrait.php
@@ -5,6 +5,7 @@
namespace Psl\IO;
use Psl;
+use Psl\DateTime\Duration;
use Psl\Str;
use function strlen;
@@ -30,7 +31,7 @@ trait ReadHandleConvenienceMethodsTrait
* @throws Exception\RuntimeException If an error occurred during the operation.
* @throws Exception\TimeoutException If $timeout is reached before being able to read from the handle.
*/
- public function readAll(?int $max_bytes = null, ?float $timeout = null): string
+ public function readAll(?int $max_bytes = null, null|Duration|float $timeout = null): string
{
$to_read = $max_bytes;
@@ -78,7 +79,7 @@ static function () use ($data): void {
* @throws Exception\RuntimeException If an error occurred during the operation.
* @throws Exception\TimeoutException If $timeout is reached before being able to read from the handle.
*/
- public function readFixedSize(int $size, ?float $timeout = null): string
+ public function readFixedSize(int $size, null|Duration|float $timeout = null): string
{
$data = $this->readAll($size, $timeout);
diff --git a/src/Psl/IO/ReadHandleInterface.php b/src/Psl/IO/ReadHandleInterface.php
index 801d5804..97dba906 100644
--- a/src/Psl/IO/ReadHandleInterface.php
+++ b/src/Psl/IO/ReadHandleInterface.php
@@ -4,6 +4,8 @@
namespace Psl\IO;
+use Psl\DateTime\Duration;
+
/**
* An `IO\Handle` that is readable.
*/
@@ -41,7 +43,7 @@ public function tryRead(?int $max_bytes = null): string;
* Up to `$max_bytes` may be allocated in a buffer; large values may lead to
* unnecessarily hitting the request memory limit.
*/
- public function read(?int $max_bytes = null, ?float $timeout = null): string;
+ public function read(?int $max_bytes = null, null|Duration|float $timeout = null): string;
/**
* Read until there is no more data to read.
@@ -59,7 +61,7 @@ public function read(?int $max_bytes = null, ?float $timeout = null): string;
* @throws Exception\RuntimeException If an error occurred during the operation.
* @throws Exception\TimeoutException If $timeout is reached before being able to read from the handle.
*/
- public function readAll(?int $max_bytes = null, ?float $timeout = null): string;
+ public function readAll(?int $max_bytes = null, null|Duration|float $timeout = null): string;
/**
* Read a fixed amount of data.
@@ -74,5 +76,5 @@ public function readAll(?int $max_bytes = null, ?float $timeout = null): string;
* @throws Exception\RuntimeException If an error occurred during the operation.
* @throws Exception\TimeoutException If $timeout is reached before being able to read from the handle.
*/
- public function readFixedSize(int $size, ?float $timeout = null): string;
+ public function readFixedSize(int $size, null|Duration|float $timeout = null): string;
}
diff --git a/src/Psl/IO/ReadStreamHandle.php b/src/Psl/IO/ReadStreamHandle.php
index ad7269bc..9299ffc6 100644
--- a/src/Psl/IO/ReadStreamHandle.php
+++ b/src/Psl/IO/ReadStreamHandle.php
@@ -4,6 +4,7 @@
namespace Psl\IO;
+use Psl\DateTime\Duration;
use Psl\IO;
/**
@@ -34,7 +35,7 @@ public function tryRead(?int $max_bytes = null): string
/**
* {@inheritDoc}
*/
- public function read(?int $max_bytes = null, ?float $timeout = null): string
+ public function read(?int $max_bytes = null, null|Duration|float $timeout = null): string
{
return $this->handle->read($max_bytes, $timeout);
}
diff --git a/src/Psl/IO/ReadWriteStreamHandle.php b/src/Psl/IO/ReadWriteStreamHandle.php
index 3fae5178..d1595c4f 100644
--- a/src/Psl/IO/ReadWriteStreamHandle.php
+++ b/src/Psl/IO/ReadWriteStreamHandle.php
@@ -4,6 +4,7 @@
namespace Psl\IO;
+use Psl\DateTime\Duration;
use Psl\IO;
/**
@@ -35,7 +36,7 @@ public function tryRead(?int $max_bytes = null): string
/**
* {@inheritDoc}
*/
- public function read(?int $max_bytes = null, ?float $timeout = null): string
+ public function read(?int $max_bytes = null, null|Duration|float $timeout = null): string
{
return $this->handle->read($max_bytes, $timeout);
}
@@ -51,7 +52,7 @@ public function tryWrite(string $bytes): int
/**
* {@inheritDoc}
*/
- public function write(string $bytes, ?float $timeout = null): int
+ public function write(string $bytes, null|Duration|float $timeout = null): int
{
return $this->handle->write($bytes, $timeout);
}
diff --git a/src/Psl/IO/Reader.php b/src/Psl/IO/Reader.php
index 11ec1462..29844e17 100644
--- a/src/Psl/IO/Reader.php
+++ b/src/Psl/IO/Reader.php
@@ -4,6 +4,7 @@
namespace Psl\IO;
+use Psl\DateTime\Duration;
use Psl\Str;
use function strlen;
@@ -29,7 +30,7 @@ public function __construct(ReadHandleInterface $handle)
/**
* {@inheritDoc}
*/
- public function readFixedSize(int $size, ?float $timeout = null): string
+ public function readFixedSize(int $size, null|Duration|float $timeout = null): string
{
$timer = new Internal\OptionalIncrementalTimeout(
$timeout,
@@ -72,7 +73,7 @@ function (): void {
* @throws Exception\RuntimeException If an error occurred during the operation.
* @throws Exception\TimeoutException If $timeout is reached before being able to read from the handle.
*/
- private function fillBuffer(?int $desired_bytes, ?float $timeout): void
+ private function fillBuffer(?int $desired_bytes, null|Duration|float $timeout): void
{
$chunk = $this->handle->read($desired_bytes, $timeout);
if ($chunk === '') {
@@ -89,7 +90,7 @@ private function fillBuffer(?int $desired_bytes, ?float $timeout): void
* @throws Exception\RuntimeException If an error occurred during the operation, or reached end of file.
* @throws Exception\TimeoutException If $timeout is reached before being able to read from the handle.
*/
- public function readByte(?float $timeout = null): string
+ public function readByte(null|Duration|float $timeout = null): string
{
if ($this->buffer === '' && !$this->eof) {
$this->fillBuffer(null, $timeout);
@@ -118,7 +119,7 @@ public function readByte(?float $timeout = null): string
* @throws Exception\RuntimeException If an error occurred during the operation.
* @throws Exception\TimeoutException If $timeout is reached before being able to read from the handle.
*/
- public function readLine(?float $timeout = null): ?string
+ public function readLine(null|Duration|float $timeout = null): ?string
{
$timer = new Internal\OptionalIncrementalTimeout(
$timeout,
@@ -154,7 +155,7 @@ static function (): void {
* @throws Exception\RuntimeException If an error occurred during the operation.
* @throws Exception\TimeoutException If $timeout is reached before being able to read from the handle.
*/
- public function readUntil(string $suffix, ?float $timeout = null): ?string
+ public function readUntil(string $suffix, null|Duration|float $timeout = null): ?string
{
$buf = $this->buffer;
$idx = strpos($buf, $suffix);
@@ -198,7 +199,7 @@ static function () use ($suffix): void {
/**
* {@inheritDoc}
*/
- public function read(?int $max_bytes = null, ?float $timeout = null): string
+ public function read(?int $max_bytes = null, null|Duration|float $timeout = null): string
{
if ($this->eof) {
return '';
diff --git a/src/Psl/IO/SeekReadStreamHandle.php b/src/Psl/IO/SeekReadStreamHandle.php
index aa4d7e02..41bee87e 100644
--- a/src/Psl/IO/SeekReadStreamHandle.php
+++ b/src/Psl/IO/SeekReadStreamHandle.php
@@ -4,6 +4,7 @@
namespace Psl\IO;
+use Psl\DateTime\Duration;
use Psl\IO;
/**
@@ -34,7 +35,7 @@ public function tryRead(?int $max_bytes = null): string
/**
* {@inheritDoc}
*/
- public function read(?int $max_bytes = null, ?float $timeout = null): string
+ public function read(?int $max_bytes = null, null|Duration|float $timeout = null): string
{
return $this->handle->read($max_bytes, $timeout);
}
diff --git a/src/Psl/IO/SeekReadWriteStreamHandle.php b/src/Psl/IO/SeekReadWriteStreamHandle.php
index 260edcbf..0d3958ba 100644
--- a/src/Psl/IO/SeekReadWriteStreamHandle.php
+++ b/src/Psl/IO/SeekReadWriteStreamHandle.php
@@ -4,6 +4,7 @@
namespace Psl\IO;
+use Psl\DateTime\Duration;
use Psl\IO;
/**
@@ -35,7 +36,7 @@ public function tryRead(?int $max_bytes = null): string
/**
* {@inheritDoc}
*/
- public function read(?int $max_bytes = null, ?float $timeout = null): string
+ public function read(?int $max_bytes = null, null|Duration|float $timeout = null): string
{
return $this->handle->read($max_bytes, $timeout);
}
@@ -51,7 +52,7 @@ public function tryWrite(string $bytes): int
/**
* {@inheritDoc}
*/
- public function write(string $bytes, ?float $timeout = null): int
+ public function write(string $bytes, null|Duration|float $timeout = null): int
{
return $this->handle->write($bytes, $timeout);
}
diff --git a/src/Psl/IO/SeekWriteStreamHandle.php b/src/Psl/IO/SeekWriteStreamHandle.php
index d12f0f7e..99492200 100644
--- a/src/Psl/IO/SeekWriteStreamHandle.php
+++ b/src/Psl/IO/SeekWriteStreamHandle.php
@@ -4,6 +4,7 @@
namespace Psl\IO;
+use Psl\DateTime\Duration;
use Psl\IO;
/**
@@ -34,7 +35,7 @@ public function tryWrite(string $bytes): int
/**
* {@inheritDoc}
*/
- public function write(string $bytes, ?float $timeout = null): int
+ public function write(string $bytes, null|Duration|float $timeout = null): int
{
return $this->handle->write($bytes, $timeout);
}
diff --git a/src/Psl/IO/WriteHandleConvenienceMethodsTrait.php b/src/Psl/IO/WriteHandleConvenienceMethodsTrait.php
index 19cceb7e..4991c5b9 100644
--- a/src/Psl/IO/WriteHandleConvenienceMethodsTrait.php
+++ b/src/Psl/IO/WriteHandleConvenienceMethodsTrait.php
@@ -5,6 +5,7 @@
namespace Psl\IO;
use Psl;
+use Psl\DateTime\Duration;
use Psl\Str;
use function strlen;
@@ -30,7 +31,7 @@ trait WriteHandleConvenienceMethodsTrait
* @throws Exception\RuntimeException If an error occurred during the operation.
* @throws Exception\TimeoutException If reached timeout before completing the operation.
*/
- public function writeAll(string $bytes, ?float $timeout = null): void
+ public function writeAll(string $bytes, null|Duration|float $timeout = null): void
{
if ($bytes === '') {
return;
diff --git a/src/Psl/IO/WriteHandleInterface.php b/src/Psl/IO/WriteHandleInterface.php
index 6a47aed5..17038bde 100644
--- a/src/Psl/IO/WriteHandleInterface.php
+++ b/src/Psl/IO/WriteHandleInterface.php
@@ -4,6 +4,8 @@
namespace Psl\IO;
+use Psl\DateTime\Duration;
+
/**
* An interface for a writable Handle.
*/
@@ -34,7 +36,7 @@ public function tryWrite(string $bytes): int;
*
* @return int<0, max> the number of bytes written, which may be less than the length of input string.
*/
- public function write(string $bytes, ?float $timeout = null): int;
+ public function write(string $bytes, null|Duration|float $timeout = null): int;
/**
* Write all of the requested data.
@@ -51,5 +53,5 @@ public function write(string $bytes, ?float $timeout = null): int;
* @throws Exception\RuntimeException If an error occurred during the operation.
* @throws Exception\TimeoutException If reached timeout before completing the operation.
*/
- public function writeAll(string $bytes, ?float $timeout = null): void;
+ public function writeAll(string $bytes, null|Duration|float $timeout = null): void;
}
diff --git a/src/Psl/IO/WriteStreamHandle.php b/src/Psl/IO/WriteStreamHandle.php
index 2bb4624d..bec4738d 100644
--- a/src/Psl/IO/WriteStreamHandle.php
+++ b/src/Psl/IO/WriteStreamHandle.php
@@ -4,6 +4,7 @@
namespace Psl\IO;
+use Psl\DateTime\Duration;
use Psl\IO;
/**
@@ -34,7 +35,7 @@ public function tryWrite(string $bytes): int
/**
* {@inheritDoc}
*/
- public function write(string $bytes, ?float $timeout = null): int
+ public function write(string $bytes, null|Duration|float $timeout = null): int
{
return $this->handle->write($bytes, $timeout);
}
diff --git a/src/Psl/IO/streaming.php b/src/Psl/IO/streaming.php
index faead9e1..beceac3e 100644
--- a/src/Psl/IO/streaming.php
+++ b/src/Psl/IO/streaming.php
@@ -7,6 +7,7 @@
use Generator;
use Psl;
use Psl\Channel;
+use Psl\DateTime\Duration;
use Psl\Result;
use Psl\Str;
use Revolt\EventLoop;
@@ -35,7 +36,7 @@
*
* @return Generator
*/
-function streaming(iterable $handles, ?float $timeout = null): Generator
+function streaming(iterable $handles, null|Duration|float $timeout = null): Generator
{
/**
* @psalm-suppress UnnecessaryVarAnnotation
@@ -72,6 +73,10 @@ function streaming(iterable $handles, ?float $timeout = null): Generator
$timeout_watcher = null;
if ($timeout !== null) {
+ if ($timeout instanceof Duration) {
+ $timeout = $timeout->getTotalSeconds();
+ }
+
$timeout_watcher = EventLoop::delay($timeout, static function () use ($sender): void {
/** @var Result\ResultInterface $failure */
$failure = new Result\Failure(
diff --git a/src/Psl/Internal/Loader.php b/src/Psl/Internal/Loader.php
index 70b722dd..13265464 100644
--- a/src/Psl/Internal/Loader.php
+++ b/src/Psl/Internal/Loader.php
@@ -54,6 +54,22 @@ final class Loader
'Psl\\Str\\ALPHABET' => 'Psl/Str/constants.php',
'Psl\\Str\\ALPHABET_ALPHANUMERIC' => 'Psl/Str/constants.php',
'Psl\\Filesystem\\SEPARATOR' => 'Psl/Filesystem/constants.php',
+ 'Psl\\DateTime\\NANOSECONDS_PER_MICROSECOND' => 'Psl/DateTime/constants.php',
+ 'Psl\\DateTime\\NANOSECONDS_PER_MILLISECOND' => 'Psl/DateTime/constants.php',
+ 'Psl\\DateTime\\NANOSECONDS_PER_SECOND' => 'Psl/DateTime/constants.php',
+ 'Psl\\DateTime\\MICROSECONDS_PER_MILLISECOND' => 'Psl/DateTime/constants.php',
+ 'Psl\\DateTime\\MICROSECONDS_PER_SECOND' => 'Psl/DateTime/constants.php',
+ 'Psl\\DateTime\\MILLISECONDS_PER_SECOND' => 'Psl/DateTime/constants.php',
+ 'Psl\\DateTime\\SECONDS_PER_MINUTE' => 'Psl/DateTime/constants.php',
+ 'Psl\\DateTime\\SECONDS_PER_HOUR' => 'Psl/DateTime/constants.php',
+ 'Psl\\DateTime\\SECONDS_PER_DAY' => 'Psl/DateTime/constants.php',
+ 'Psl\\DateTime\\SECONDS_PER_WEEK' => 'Psl/DateTime/constants.php',
+ 'Psl\\DateTime\\MINUTES_PER_HOUR' => 'Psl/DateTime/constants.php',
+ 'Psl\\DateTime\\MINUTES_PER_DAY' => 'Psl/DateTime/constants.php',
+ 'Psl\\DateTime\\MINUTES_PER_WEEK' => 'Psl/DateTime/constants.php',
+ 'Psl\\DateTime\\HOURS_PER_DAY' => 'Psl/DateTime/constants.php',
+ 'Psl\\DateTime\\HOURS_PER_WEEK' => 'Psl/DateTime/constants.php',
+ 'Psl\\DateTime\\DAYS_PER_WEEK' => 'Psl/DateTime/constants.php',
];
public const FUNCTIONS = [
@@ -520,6 +536,8 @@ final class Loader
'Psl\\Range\\to' => 'Psl/Range/to.php',
'Psl\\Range\\between' => 'Psl/Range/between.php',
'Psl\\Range\\full' => 'Psl/Range/full.php',
+ 'Psl\\DateTime\\is_leap_year' => 'Psl/DateTime/is_leap_year.php',
+ 'Psl\\DateTime\\Internal\\zone_override' => 'Psl/DateTime/Internal/zone_override.php',
];
public const INTERFACES = [
@@ -611,6 +629,9 @@ final class Loader
'Psl\\Range\\RangeInterface' => 'Psl/Range/RangeInterface.php',
'Psl\\Range\\LowerBoundRangeInterface' => 'Psl/Range/LowerBoundRangeInterface.php',
'Psl\\Range\\UpperBoundRangeInterface' => 'Psl/Range/UpperBoundRangeInterface.php',
+ 'Psl\\DateTime\\Exception\\ExceptionInterface' => 'Psl/DateTime/Exception/ExceptionInterface.php',
+ 'Psl\\DateTime\\DateTimeInterface' => 'Psl/DateTime/DateTimeInterface.php',
+ 'Psl\\DateTime\\TemporalInterface' => 'Psl/DateTime/TemporalInterface.php',
];
public const TRAITS = [
@@ -618,6 +639,8 @@ final class Loader
'Psl\\IO\\ReadHandleConvenienceMethodsTrait' => 'Psl/IO/ReadHandleConvenienceMethodsTrait.php',
'Psl\\IO\\WriteHandleConvenienceMethodsTrait' => 'Psl/IO/WriteHandleConvenienceMethodsTrait.php',
'Psl\\Channel\\Internal\\ChannelSideTrait' => 'Psl/Channel/Internal/ChannelSideTrait.php',
+ 'Psl\\DateTime\\DateTimeConvenienceMethodsTrait' => 'Psl/DateTime/DateTimeConvenienceMethodsTrait.php',
+ 'Psl\\DateTime\\TemporalConvenienceMethodsTrait' => 'Psl/DateTime/TemporalConvenienceMethodsTrait.php',
];
public const CLASSES = [
@@ -814,6 +837,13 @@ final class Loader
'Psl\\Range\\ToRange' => 'Psl/Range/ToRange.php',
'Psl\\Range\\BetweenRange' => 'Psl/Range/BetweenRange.php',
'Psl\\Range\\FullRange' => 'Psl/Range/FullRange.php',
+ 'Psl\\DateTime\\Exception\\InvalidArgumentException' => 'Psl/DateTime/Exception/InvalidArgumentException.php',
+ 'Psl\\DateTime\\Exception\\OverflowException' => 'Psl/DateTime/Exception/OverflowException.php',
+ 'Psl\\DateTime\\Exception\\UnderflowException' => 'Psl/DateTime/Exception/UnderflowException.php',
+ 'Psl\\DateTime\\DateTime' => 'Psl/DateTime/DateTime.php',
+ 'Psl\\DateTime\\Duration' => 'Psl/DateTime/Interval.php',
+ 'Psl\\DateTime\\Timestamp' => 'Psl/DateTime/Timestamp.php',
+ 'Psl\\DateTime\\TimezoneLocation' => 'Psl/DateTime/TimezoneLocation.php',
];
public const ENUMS = [
@@ -830,6 +860,12 @@ final class Loader
'Psl\\Password\\Algorithm' => 'Psl/Password/Algorithm.php',
'Psl\\Shell\\ErrorOutputBehavior' => 'Psl/Shell/ErrorOutputBehavior.php',
'Psl\\Locale\\Locale' => 'Psl/Locale/Locale.php',
+ 'Psl\\DateTime\\DateFormat' => 'Psl/DateTime/DateFormat.php',
+ 'Psl\\DateTime\\Era' => 'Psl/DateTime/Era.php',
+ 'Psl\\DateTime\\Meridiem' => 'Psl/DateTime/Meridiem.php',
+ 'Psl\\DateTime\\Month' => 'Psl/DateTime/Weekday.php',
+ 'Psl\\DateTime\\Timezone' => 'Psl/DateTime/Timezone.php',
+ 'Psl\\DateTime\\Weekday' => 'Psl/DateTime/Weekday.php',
];
public const TYPE_CONSTANTS = 1;
diff --git a/src/Psl/Network/Internal/Socket.php b/src/Psl/Network/Internal/Socket.php
index 489735af..460e9b6c 100644
--- a/src/Psl/Network/Internal/Socket.php
+++ b/src/Psl/Network/Internal/Socket.php
@@ -4,6 +4,7 @@
namespace Psl\Network\Internal;
+use Psl\DateTime\Duration;
use Psl\IO;
use Psl\IO\Exception;
use Psl\IO\Internal;
@@ -43,7 +44,7 @@ public function tryRead(?int $max_bytes = null): string
/**
* {@inheritDoc}
*/
- public function read(?int $max_bytes = null, ?float $timeout = null): string
+ public function read(?int $max_bytes = null, null|Duration|float $timeout = null): string
{
return $this->handle->read($max_bytes, $timeout);
}
@@ -59,7 +60,7 @@ public function tryWrite(string $bytes): int
/**
* {@inheritDoc}
*/
- public function write(string $bytes, ?float $timeout = null): int
+ public function write(string $bytes, null|Duration|float $timeout = null): int
{
return $this->handle->write($bytes, $timeout);
}
diff --git a/src/Psl/Network/Internal/socket_connect.php b/src/Psl/Network/Internal/socket_connect.php
index 50420a27..c981aa6c 100644
--- a/src/Psl/Network/Internal/socket_connect.php
+++ b/src/Psl/Network/Internal/socket_connect.php
@@ -4,6 +4,7 @@
namespace Psl\Network\Internal;
+use Psl\DateTime\Duration;
use Psl\Internal;
use Psl\Network\Exception;
use Revolt\EventLoop;
@@ -28,7 +29,7 @@
*
* @codeCoverageIgnore
*/
-function socket_connect(string $uri, array $context = [], ?float $timeout = null): mixed
+function socket_connect(string $uri, array $context = [], null|Duration|float $timeout = null): mixed
{
return Internal\suppress(static function () use ($uri, $context, $timeout): mixed {
$context = stream_context_create($context);
@@ -42,6 +43,10 @@ function socket_connect(string $uri, array $context = [], ?float $timeout = null
$write_watcher = '';
$timeout_watcher = '';
if (null !== $timeout) {
+ if ($timeout instanceof Duration) {
+ $timeout = $timeout->getTotalSeconds();
+ }
+
$timeout_watcher = EventLoop::delay($timeout, static function () use ($suspension, &$write_watcher, $socket) {
EventLoop::cancel($write_watcher);
diff --git a/src/Psl/Shell/execute.php b/src/Psl/Shell/execute.php
index 03519e9d..47895414 100644
--- a/src/Psl/Shell/execute.php
+++ b/src/Psl/Shell/execute.php
@@ -4,6 +4,7 @@
namespace Psl\Shell;
+use Psl\DateTime\Duration;
use Psl\Dict;
use Psl\Env;
use Psl\Filesystem;
@@ -44,7 +45,7 @@ function execute(
?string $working_directory = null,
array $environment = [],
ErrorOutputBehavior $error_output_behavior = ErrorOutputBehavior::Discard,
- ?float $timeout = null
+ null|Duration|float $timeout = null
): string {
$arguments = Vec\map($arguments, Internal\escape_argument(...));
$commandline = Str\join([$command, ...$arguments], ' ');
diff --git a/src/Psl/TCP/connect.php b/src/Psl/TCP/connect.php
index e69f8a53..b737fd31 100644
--- a/src/Psl/TCP/connect.php
+++ b/src/Psl/TCP/connect.php
@@ -4,6 +4,7 @@
namespace Psl\TCP;
+use Psl\DateTime\Duration;
use Psl\Network;
/**
@@ -15,12 +16,8 @@
* @throws Network\Exception\RuntimeException If failed to connect to client on the given address.
* @throws Network\Exception\TimeoutException If $timeout is non-null, and the operation timed-out.
*/
-function connect(
- string $host,
- int $port = 0,
- ?ConnectOptions $options = null,
- ?float $timeout = null,
-): Network\StreamSocketInterface {
+function connect(string $host, int $port = 0, ?ConnectOptions $options = null, null|Duration|float $timeout = null): Network\StreamSocketInterface
+{
$options ??= ConnectOptions::create();
$context = [
@@ -29,7 +26,7 @@ function connect(
]
];
- $socket = Network\Internal\socket_connect("tcp://{$host}:{$port}", $context, $timeout);
+ $socket = Network\Internal\socket_connect("tcp://$host:$port", $context, $timeout);
/** @psalm-suppress MissingThrowsDocblock */
return new Network\Internal\Socket($socket);
diff --git a/src/Psl/Unix/connect.php b/src/Psl/Unix/connect.php
index acca9b49..a3894a46 100644
--- a/src/Psl/Unix/connect.php
+++ b/src/Psl/Unix/connect.php
@@ -4,6 +4,7 @@
namespace Psl\Unix;
+use Psl\DateTime\Duration;
use Psl\Network;
use Psl\OS;
@@ -15,7 +16,7 @@
* @throws Network\Exception\RuntimeException If failed to connect to client on the given address.
* @throws Network\Exception\TimeoutException If $timeout is non-null, and the operation timed-out.
*/
-function connect(string $path, ?float $timeout = null): Network\StreamSocketInterface
+function connect(string $path, null|Duration|float $timeout = null): Network\StreamSocketInterface
{
// @codeCoverageIgnoreStart
if (OS\is_windows()) {
@@ -23,7 +24,7 @@ function connect(string $path, ?float $timeout = null): Network\StreamSocketInte
}
// @codeCoverageIgnoreEnd
- $socket = Network\Internal\socket_connect("unix://{$path}", timeout: $timeout);
+ $socket = Network\Internal\socket_connect("unix://$path", timeout: $timeout);
/** @psalm-suppress MissingThrowsDocblock */
return new Network\Internal\Socket($socket);
diff --git a/tests/unit/DateTime/TimeIntervalTest.php b/tests/unit/DateTime/TimeIntervalTest.php
new file mode 100644
index 00000000..959f9cd9
--- /dev/null
+++ b/tests/unit/DateTime/TimeIntervalTest.php
@@ -0,0 +1,348 @@
+getHours());
+ static::assertEquals(2, $t->getMinutes());
+ static::assertEquals(3, $t->getSeconds());
+ static::assertEquals(4, $t->getNanoseconds());
+ static::assertEquals([1, 2, 3, 4], $t->getParts());
+ }
+
+ public function provideGetTotalHours(): array
+ {
+ return [
+ [0, 0, 0, 0, 0.0],
+ [0, 0, 0, 1, 2.777777777777778E-13],
+ [1, 0, 0, 0, 1.0],
+ [1, 30, 0, 0, 1.5],
+ [2, 15, 30, 0, 2.2583333333333333],
+ [-1, 0, 0, 0, -1.0],
+ [-1, -30, 0, 0, -1.5],
+ [-2, -15, -30, 0, -2.2583333333333333],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetTotalHours
+ */
+ public function testGetTotalHours(int $hours, int $minutes, int $seconds, int $nanoseconds, float $expectedHours): void
+ {
+ $time = DateTime\Duration::fromParts($hours, $minutes, $seconds, $nanoseconds);
+ static::assertEquals($expectedHours, $time->getTotalHours());
+ }
+
+ public function provideGetTotalMinutes(): array
+ {
+ return [
+ [0, 0, 0, 0, 0.0],
+ [0, 0, 0, 1, 1.6666666666666667E-11],
+ [1, 0, 0, 0, 60.0],
+ [1, 30, 0, 0, 90.0],
+ [2, 15, 30, 0, 135.5],
+ [-1, 0, 0, 0, -60.0],
+ [-1, -30, 0, 0, -90.0],
+ [-2, -15, -30, 0, -135.5],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetTotalMinutes
+ */
+ public function testGetTotalMinutes(int $hours, int $minutes, int $seconds, int $nanoseconds, float $expectedMinutes): void
+ {
+ $time = DateTime\Duration::fromParts($hours, $minutes, $seconds, $nanoseconds);
+ static::assertEquals($expectedMinutes, $time->getTotalMinutes());
+ }
+
+ public function provideGetTotalSeconds(): array
+ {
+ return [
+ [0, 0, 0, 0, 0.0],
+ [0, 0, 0, 1, 0.000000001],
+ [1, 0, 0, 0, 3600.0],
+ [1, 30, 0, 0, 5400.0],
+ [2, 15, 30, 0, 8130.0],
+ [-1, 0, 0, 0, -3600.0],
+ [-1, -30, 0, 0, -5400.0],
+ [-2, -15, -30, 0, -8130.0],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetTotalSeconds
+ */
+ public function testGetTotalSeconds(int $hours, int $minutes, int $seconds, int $nanoseconds, float $expectedSeconds): void
+ {
+ $time = DateTime\Duration::fromParts($hours, $minutes, $seconds, $nanoseconds);
+ static::assertEquals($expectedSeconds, $time->getTotalSeconds());
+ }
+
+ public function provideGetTotalMilliseconds(): array
+ {
+ return [
+ [0, 0, 0, 0, 0.0],
+ [0, 0, 0, 1, 0.000001],
+ [1, 0, 0, 0, 3600000.0],
+ [1, 30, 0, 0, 5400000.0],
+ [2, 15, 30, 0, 8130000.0],
+ [-1, 0, 0, 0, -3600000.0],
+ [-1, -30, 0, 0, -5400000.0],
+ [-2, -15, -30, 0, -8130000.0],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetTotalMilliseconds
+ */
+ public function testGetTotalMilliseconds(int $hours, int $minutes, int $seconds, int $nanoseconds, float $expectedMilliseconds): void
+ {
+ $time = DateTime\Duration::fromParts($hours, $minutes, $seconds, $nanoseconds);
+ static::assertEquals($expectedMilliseconds, $time->getTotalMilliseconds());
+ }
+
+ public function provideGetTotalMicroseconds(): array
+ {
+ return [
+ [0, 0, 0, 0, 0.0],
+ [0, 0, 0, 1, 0.001],
+ [1, 0, 0, 0, 3600000000.0],
+ [1, 30, 0, 0, 5400000000.0],
+ [2, 15, 30, 0, 8130000000.0],
+ [-1, 0, 0, 0, -3600000000.0],
+ [-1, -30, 0, 0, -5400000000.0],
+ [-2, -15, -30, 0, -8130000000.0],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetTotalMicroseconds
+ */
+ public function testGetTotalMicroseconds(int $hours, int $minutes, int $seconds, int $nanoseconds, float $expectedMicroseconds): void
+ {
+ $time = DateTime\Duration::fromParts($hours, $minutes, $seconds, $nanoseconds);
+ static::assertEquals($expectedMicroseconds, $time->getTotalMicroseconds());
+ }
+
+ public function testSetters(): void
+ {
+ $t = DateTime\Duration::fromParts(1, 2, 3, 4);
+
+ static::assertEquals([42, 2, 3, 4], $t->withHours(42)->getParts());
+ static::assertEquals([1, 42, 3, 4], $t->withMinutes(42)->getParts());
+ static::assertEquals([1, 2, 42, 4], $t->withSeconds(42)->getParts());
+ static::assertEquals([1, 2, 3, 42], $t->withNanoseconds(42)->getParts());
+ static::assertEquals([2, 3, 3, 4], $t->withMinutes(63)->getParts());
+ static::assertEquals([1, 3, 3, 4], $t->withSeconds(63)->getParts());
+ static::assertEquals([1, 2, 4, 42], $t->withNanoseconds(DateTime\NANOSECONDS_PER_SECOND + 42)->getParts());
+ static::assertEquals([1, 2, 3, 4], $t->getParts());
+ }
+ public function testFractionsOfSecond(): void
+ {
+ static::assertEquals([0, 0, 0, 0], DateTime\Duration::zero()->getParts());
+ static::assertEquals([0, 0, 0, 42], DateTime\Duration::nanoseconds(42)->getParts());
+ static::assertEquals([0, 0, 1, 42], DateTime\Duration::nanoseconds(DateTime\NANOSECONDS_PER_SECOND + 42)->getParts());
+ static::assertEquals([0, 0, 0, 42000], DateTime\Duration::microseconds(42)->getParts());
+ static::assertEquals([0, 0, 1, 42000], DateTime\Duration::microseconds(1000042)->getParts());
+ static::assertEquals([0, 0, 0, 42000000], DateTime\Duration::milliseconds(42)->getParts());
+ static::assertEquals([0, 0, 1, 42000000], DateTime\Duration::milliseconds(1042)->getParts());
+ }
+
+ /**
+ * @return list
+ */
+ public static function provideNormalized(): array
+ {
+ return [
+ // input seconds, input ns, normalized seconds, normalized ns
+ [0, 0, 0, 0],
+ [0, 3, 0, 3],
+ [3, 0, 3, 0],
+ [1, 3, 1, 3],
+ [1, -3, 0, DateTime\NANOSECONDS_PER_SECOND - 3],
+ [-1, 3, 0, -(DateTime\NANOSECONDS_PER_SECOND - 3)],
+ [-1, -3, -1, -3],
+ [1, DateTime\NANOSECONDS_PER_SECOND + 42, 2, 42],
+ [1, -(DateTime\NANOSECONDS_PER_SECOND + 42), 0, -42],
+ [2, -3, 1, DateTime\NANOSECONDS_PER_SECOND - 3],
+ ];
+ }
+ /**
+ * @dataProvider provideNormalized
+ */
+ public function testNormalized(int $input_s, int $input_ns, int $normalized_s, int $normalized_ns): void
+ {
+ static::assertEquals(
+ [0, 0, $normalized_s, $normalized_ns],
+ DateTime\Duration::fromParts(0, 0, $input_s, $input_ns)->getParts()
+ );
+ }
+
+ public function testNormalizedHMS(): void
+ {
+ static::assertEquals([3, 5, 4, 0], DateTime\Duration::fromParts(2, 63, 124)->getParts());
+ static::assertEquals([0, 59, 4, 0], DateTime\Duration::fromParts(2, -63, 124)->getParts());
+ static::assertEquals([-1, 0, -55, -(DateTime\NANOSECONDS_PER_SECOND - 42)], DateTime\Duration::fromParts(0, -63, 124, 42)->getParts());
+ static::assertEquals([42, 0, 0, 0], DateTime\Duration::hours(42)->getParts());
+ static::assertEquals([1, 3, 0, 0], DateTime\Duration::minutes(63)->getParts());
+ static::assertEquals([0, -1, -3, 0], DateTime\Duration::seconds(-63)->getParts());
+ static::assertEquals([0, 0, -1, 0], DateTime\Duration::nanoseconds(-DateTime\NANOSECONDS_PER_SECOND)->getParts());
+ }
+
+ /**
+ * @return list
+ */
+ public static function providePositiveNegative(): array
+ {
+ return [
+ // h, m, s, ns, expected sign
+ [0, 0, 0, 0, 0],
+ [0, 42, 0, 0, 1],
+ [0, 0, -42, 0, -1],
+ [1, -63, 0, 0, -1],
+ ];
+ }
+ /**
+ * @dataProvider providePositiveNegative
+ */
+ public function testPositiveNegative(int $h, int $m, int $s, int $ns, int $expected_sign): void
+ {
+ $t = DateTime\Duration::fromParts($h, $m, $s, $ns);
+ static::assertEquals($expected_sign === 0, $t->isZero());
+ static::assertEquals($expected_sign === 1, $t->isPositive());
+ static::assertEquals($expected_sign === -1, $t->isNegative());
+ }
+
+ /**
+ * @return list
+ */
+ public static function provideCompare(): array
+ {
+ return [
+ array(DateTime\Duration::hours(1), DateTime\Duration::minutes(42), 1),
+ array(DateTime\Duration::minutes(2), DateTime\Duration::seconds(120), 0),
+ array(DateTime\Duration::zero(), DateTime\Duration::nanoseconds(1), -1),
+ ];
+ }
+ /**
+ * @dataProvider provideCompare
+ */
+ public function testCompare(DateTime\Duration $a, DateTime\Duration $b, int $expected): void
+ {
+ static::assertEquals($expected, $a->compare($b));
+ static::assertEquals(-$expected, $b->compare($a));
+ static::assertEquals($expected === 0, $a->isEqual($b));
+ static::assertEquals($expected === -1, $a->isShorter($b));
+ static::assertEquals($expected !== 1, $a->isShorterOrEqual($b));
+ static::assertEquals($expected === 1, $a->isLonger($b));
+ static::assertEquals($expected !== -1, $a->isLongerOrEqual($b));
+ static::assertFalse($a->isBetweenExclusive($a, $a));
+ static::assertFalse($a->isBetweenExclusive($a, $b));
+ static::assertFalse($a->isBetweenExclusive($b, $a));
+ static::assertFalse($a->isBetweenExclusive($b, $b));
+ static::assertTrue($a->isBetweenInclusive($a, $a));
+ static::assertTrue($a->isBetweenInclusive($a, $b));
+ static::assertTrue($a->isBetweenInclusive($b, $a));
+ static::assertEquals($expected === 0, $a->isBetweenInclusive($b, $b));
+ }
+
+ public function testIsBetween(): void
+ {
+ $a = DateTime\Duration::hours(1);
+ $b = DateTime\Duration::minutes(64);
+ $c = DateTime\Duration::fromParts(1, 30);
+ static::assertTrue($b->isBetweenExclusive($a, $c));
+ static::assertTrue($b->isBetweenExclusive($c, $a));
+ static::assertTrue($b->isBetweenInclusive($a, $c));
+ static::assertTrue($b->isBetweenInclusive($c, $a));
+ static::assertFalse($a->isBetweenExclusive($b, $c));
+ static::assertFalse($a->isBetweenInclusive($c, $b));
+ static::assertFalse($c->isBetweenInclusive($a, $b));
+ static::assertFalse($c->isBetweenExclusive($b, $a));
+ }
+
+ public function testOperations(): void
+ {
+ $z = DateTime\Duration::zero();
+ $a = DateTime\Duration::fromParts(0, 2, 25);
+ $b = DateTime\Duration::fromParts(0, 0, -63, 42);
+ static::assertEquals([0, 0, 0, 0], $z->invert()->getParts());
+ static::assertEquals([0, -2, -25, 0], $a->invert()->getParts());
+ static::assertEquals([0, 1, 2, DateTime\NANOSECONDS_PER_SECOND - 42], $b->invert()->getParts());
+ static::assertEquals($a->getParts(), $z->plus($a)->getParts());
+ static::assertEquals($b->getParts(), $b->plus($z)->getParts());
+ static::assertEquals($b->getParts(), $z->minus($b)->getParts());
+ static::assertEquals($a->getParts(), $a->minus($z)->getParts());
+ static::assertEquals([0, 1, 22, 42], $a->plus($b)->getParts());
+ static::assertEquals([0, 1, 22, 42], $b->plus($a)->getParts());
+ static::assertEquals([0, 3, 27, DateTime\NANOSECONDS_PER_SECOND - 42], $a->minus($b)->getParts());
+ static::assertEquals([0, -3, -27, -(DateTime\NANOSECONDS_PER_SECOND - 42)], $b->minus($a)->getParts());
+ static::assertEquals($b->invert()->plus($a)->getParts(), $a->minus($b)->getParts());
+ }
+
+ /**
+ * @return list
+ */
+ public static function provideToString(): array
+ {
+ return [
+ // h, m, s, ns, expected output
+ [42, 0, 0, 0, '42 hour(s)'],
+ [0, 42, 0, 0, '42 minute(s)'],
+ [0, 0, 42, 0, '42 second(s)'],
+ [0, 0, 0, 0, '0 second(s)'],
+ [0, 0, 0, 42, '0 second(s)'], // rounded because default $max_decimals = 3
+ [0, 0, 1, 42, '1 second(s)'],
+ [0, 0, 1, 20000000, '1.02 second(s)'],
+ [1, 2, 0, 0, '1 hour(s), 2 minute(s)'],
+ [1, 0, 3, 0, '1 hour(s), 0 minute(s), 3 second(s)'],
+ [0, 2, 3, 0, '2 minute(s), 3 second(s)'],
+ [1, 2, 3, 0, '1 hour(s), 2 minute(s), 3 second(s)'],
+ [1, 0, 0, 42000000, '1 hour(s), 0 minute(s), 0.042 second(s)'],
+ [-42, 0, -42, 0, '-42 hour(s), 0 minute(s), -42 second(s)'],
+ [-42, 0, -42, -420000000, '-42 hour(s), 0 minute(s), -42.42 second(s)'],
+ [0, 0, 0, -420000000, '-0.42 second(s)'],
+ ];
+ }
+
+ /**
+ * @dataProvider provideToString
+ */
+ public function testToString(int $h, int $m, int $s, int $ns, string $expected): void
+ {
+ static::assertEquals($expected, DateTime\Duration::fromParts($h, $m, $s, $ns)->toString());
+ }
+
+ public function testSerialization(): void
+ {
+ $timeInterval = DateTime\Duration::fromParts(1, 30, 45, 500000000);
+ $serialized = serialize($timeInterval);
+ $deserialized = unserialize($serialized);
+
+ static::assertEquals($timeInterval, $deserialized);
+ }
+
+ public function testJsonEncoding(): void
+ {
+ $timeInterval = DateTime\Duration::fromParts(1, 30, 45, 500000000);
+ $jsonEncoded = Json\encode($timeInterval);
+ $jsonDecoded = Json\decode($jsonEncoded);
+
+ static::assertSame(['hours' => 1, 'minutes' => 30, 'seconds' => 45, 'nanoseconds' => 500000000], $jsonDecoded);
+ }
+}