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); + } +}