diff --git a/Makefile b/Makefile index ba7b8ffe..7cad2c6f 100644 --- a/Makefile +++ b/Makefile @@ -22,8 +22,8 @@ compare-benchmark-to-reference: ./vendor/bin/phpbench run --config config/phpbench.json --ref=benchmark_reference static-analysis: ## run static analysis checks - ./vendor/bin/psalm -c config/psalm.xml --show-info=true --no-cache --threads=1 - ./vendor/bin/psalm -c config/psalm.xml tests/static-analysis --no-cache --threads=1 + ./vendor/bin/psalm -c config/psalm.xml --show-info=true --no-cache --threads=2 + ./vendor/bin/psalm -c config/psalm.xml tests/static-analysis --no-cache --threads=2 type-coverage: ## send static analysis type coverage metrics to https://shepherd.dev/ ./vendor/bin/psalm -c config/psalm.xml --shepherd --stats --threads=1 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/docs/README.md b/docs/README.md index 2dc967f7..3c88ee36 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,6 +20,7 @@ - [Psl\Collection](./component/collection.md) - [Psl\Comparison](./component/comparison.md) - [Psl\DataStructure](./component/data-structure.md) +- [Psl\DateTime](./component/date-time.md) - [Psl\Dict](./component/dict.md) - [Psl\Encoding\Base64](./component/encoding-base64.md) - [Psl\Encoding\Hex](./component/encoding-hex.md) diff --git a/docs/component/date-time.md b/docs/component/date-time.md new file mode 100644 index 00000000..3f443cc1 --- /dev/null +++ b/docs/component/date-time.md @@ -0,0 +1,64 @@ + + +[*index](./../README.md) + +--- + +### `Psl\DateTime` Component + +#### `Constants` + +- [DAYS_PER_WEEK](./../../src/Psl/DateTime/constants.php#L0) +- [HOURS_PER_DAY](./../../src/Psl/DateTime/constants.php#L0) +- [HOURS_PER_WEEK](./../../src/Psl/DateTime/constants.php#L0) +- [MICROSECONDS_PER_MILLISECOND](./../../src/Psl/DateTime/constants.php#L0) +- [MICROSECONDS_PER_SECOND](./../../src/Psl/DateTime/constants.php#L0) +- [MILLISECONDS_PER_SECOND](./../../src/Psl/DateTime/constants.php#L0) +- [MINUTES_PER_DAY](./../../src/Psl/DateTime/constants.php#L0) +- [MINUTES_PER_HOUR](./../../src/Psl/DateTime/constants.php#L0) +- [MINUTES_PER_WEEK](./../../src/Psl/DateTime/constants.php#L0) +- [MONTHS_PER_YEAR](./../../src/Psl/DateTime/constants.php#L0) +- [NANOSECONDS_PER_MICROSECOND](./../../src/Psl/DateTime/constants.php#L0) +- [NANOSECONDS_PER_MILLISECOND](./../../src/Psl/DateTime/constants.php#L0) +- [NANOSECONDS_PER_SECOND](./../../src/Psl/DateTime/constants.php#L0) +- [SECONDS_PER_DAY](./../../src/Psl/DateTime/constants.php#L0) +- [SECONDS_PER_HOUR](./../../src/Psl/DateTime/constants.php#L0) +- [SECONDS_PER_MINUTE](./../../src/Psl/DateTime/constants.php#L0) +- [SECONDS_PER_WEEK](./../../src/Psl/DateTime/constants.php#L0) + +#### `Functions` + +- [is_leap_year](./../../src/Psl/DateTime/is_leap_year.php#L17) + +#### `Interfaces` + +- [DateTimeInterface](./../../src/Psl/DateTime/DateTimeInterface.php#L9) +- [TemporalInterface](./../../src/Psl/DateTime/TemporalInterface.php#L20) + +#### `Classes` + +- [DateTime](./../../src/Psl/DateTime/DateTime.php#L13) +- [Duration](./../../src/Psl/DateTime/Duration.php#L30) +- [Timestamp](./../../src/Psl/DateTime/Timestamp.php#L16) + +#### `Traits` + +- [DateTimeConvenienceMethodsTrait](./../../src/Psl/DateTime/DateTimeConvenienceMethodsTrait.php#L15) +- [TemporalConvenienceMethodsTrait](./../../src/Psl/DateTime/TemporalConvenienceMethodsTrait.php#L16) + +#### `Enums` + +- [Era](./../../src/Psl/DateTime/Era.php#L14) +- [FormatDateStyle](./../../src/Psl/DateTime/FormatDateStyle.php#L23) +- [FormatPattern](./../../src/Psl/DateTime/FormatPattern.php#L15) +- [FormatTimeStyle](./../../src/Psl/DateTime/FormatTimeStyle.php#L23) +- [Meridiem](./../../src/Psl/DateTime/Meridiem.php#L14) +- [Month](./../../src/Psl/DateTime/Month.php#L15) +- [Timezone](./../../src/Psl/DateTime/Timezone.php#L21) +- [Weekday](./../../src/Psl/DateTime/Weekday.php#L15) + + diff --git a/docs/component/file.md b/docs/component/file.md index d280981d..28374bb6 100644 --- a/docs/component/file.md +++ b/docs/component/file.md @@ -28,9 +28,9 @@ #### `Classes` - [Lock](./../../src/Psl/File/Lock.php#L9) -- [ReadHandle](./../../src/Psl/File/ReadHandle.php#L10) -- [ReadWriteHandle](./../../src/Psl/File/ReadWriteHandle.php#L11) -- [WriteHandle](./../../src/Psl/File/WriteHandle.php#L11) +- [ReadHandle](./../../src/Psl/File/ReadHandle.php#L11) +- [ReadWriteHandle](./../../src/Psl/File/ReadWriteHandle.php#L12) +- [WriteHandle](./../../src/Psl/File/WriteHandle.php#L12) #### `Enums` diff --git a/docs/component/io.md b/docs/component/io.md index 66083467..b5f18496 100644 --- a/docs/component/io.md +++ b/docs/component/io.md @@ -16,7 +16,7 @@ - [input_handle](./../../src/Psl/IO/input_handle.php#L20) - [output_handle](./../../src/Psl/IO/output_handle.php#L20) - [pipe](./../../src/Psl/IO/pipe.php#L24) -- [streaming](./../../src/Psl/IO/streaming.php#L38) +- [streaming](./../../src/Psl/IO/streaming.php#L41) - [write](./../../src/Psl/IO/write.php#L21) - [write_error](./../../src/Psl/IO/write_error.php#L23) - [write_error_line](./../../src/Psl/IO/write_error_line.php#L23) @@ -41,7 +41,7 @@ - [CloseWriteHandleInterface](./../../src/Psl/IO/CloseWriteHandleInterface.php#L7) - [CloseWriteStreamHandleInterface](./../../src/Psl/IO/CloseWriteStreamHandleInterface.php#L9) - [HandleInterface](./../../src/Psl/IO/HandleInterface.php#L21) -- [ReadHandleInterface](./../../src/Psl/IO/ReadHandleInterface.php#L10) +- [ReadHandleInterface](./../../src/Psl/IO/ReadHandleInterface.php#L12) - [ReadStreamHandleInterface](./../../src/Psl/IO/ReadStreamHandleInterface.php#L9) - [ReadWriteHandleInterface](./../../src/Psl/IO/ReadWriteHandleInterface.php#L7) - [ReadWriteStreamHandleInterface](./../../src/Psl/IO/ReadWriteStreamHandleInterface.php#L9) @@ -54,32 +54,32 @@ - [SeekWriteHandleInterface](./../../src/Psl/IO/SeekWriteHandleInterface.php#L7) - [SeekWriteStreamHandleInterface](./../../src/Psl/IO/SeekWriteStreamHandleInterface.php#L9) - [StreamHandleInterface](./../../src/Psl/IO/StreamHandleInterface.php#L9) -- [WriteHandleInterface](./../../src/Psl/IO/WriteHandleInterface.php#L10) +- [WriteHandleInterface](./../../src/Psl/IO/WriteHandleInterface.php#L12) - [WriteStreamHandleInterface](./../../src/Psl/IO/WriteStreamHandleInterface.php#L9) #### `Classes` -- [CloseReadStreamHandle](./../../src/Psl/IO/CloseReadStreamHandle.php#L12) -- [CloseReadWriteStreamHandle](./../../src/Psl/IO/CloseReadWriteStreamHandle.php#L12) -- [CloseSeekReadStreamHandle](./../../src/Psl/IO/CloseSeekReadStreamHandle.php#L12) -- [CloseSeekReadWriteStreamHandle](./../../src/Psl/IO/CloseSeekReadWriteStreamHandle.php#L12) +- [CloseReadStreamHandle](./../../src/Psl/IO/CloseReadStreamHandle.php#L13) +- [CloseReadWriteStreamHandle](./../../src/Psl/IO/CloseReadWriteStreamHandle.php#L13) +- [CloseSeekReadStreamHandle](./../../src/Psl/IO/CloseSeekReadStreamHandle.php#L13) +- [CloseSeekReadWriteStreamHandle](./../../src/Psl/IO/CloseSeekReadWriteStreamHandle.php#L13) - [CloseSeekStreamHandle](./../../src/Psl/IO/CloseSeekStreamHandle.php#L10) -- [CloseSeekWriteStreamHandle](./../../src/Psl/IO/CloseSeekWriteStreamHandle.php#L12) +- [CloseSeekWriteStreamHandle](./../../src/Psl/IO/CloseSeekWriteStreamHandle.php#L13) - [CloseStreamHandle](./../../src/Psl/IO/CloseStreamHandle.php#L10) -- [CloseWriteStreamHandle](./../../src/Psl/IO/CloseWriteStreamHandle.php#L12) -- [MemoryHandle](./../../src/Psl/IO/MemoryHandle.php#L13) -- [ReadStreamHandle](./../../src/Psl/IO/ReadStreamHandle.php#L12) -- [ReadWriteStreamHandle](./../../src/Psl/IO/ReadWriteStreamHandle.php#L12) -- [Reader](./../../src/Psl/IO/Reader.php#L16) -- [SeekReadStreamHandle](./../../src/Psl/IO/SeekReadStreamHandle.php#L12) -- [SeekReadWriteStreamHandle](./../../src/Psl/IO/SeekReadWriteStreamHandle.php#L12) +- [CloseWriteStreamHandle](./../../src/Psl/IO/CloseWriteStreamHandle.php#L13) +- [MemoryHandle](./../../src/Psl/IO/MemoryHandle.php#L14) +- [ReadStreamHandle](./../../src/Psl/IO/ReadStreamHandle.php#L13) +- [ReadWriteStreamHandle](./../../src/Psl/IO/ReadWriteStreamHandle.php#L13) +- [Reader](./../../src/Psl/IO/Reader.php#L17) +- [SeekReadStreamHandle](./../../src/Psl/IO/SeekReadStreamHandle.php#L13) +- [SeekReadWriteStreamHandle](./../../src/Psl/IO/SeekReadWriteStreamHandle.php#L13) - [SeekStreamHandle](./../../src/Psl/IO/SeekStreamHandle.php#L10) -- [SeekWriteStreamHandle](./../../src/Psl/IO/SeekWriteStreamHandle.php#L12) -- [WriteStreamHandle](./../../src/Psl/IO/WriteStreamHandle.php#L12) +- [SeekWriteStreamHandle](./../../src/Psl/IO/SeekWriteStreamHandle.php#L13) +- [WriteStreamHandle](./../../src/Psl/IO/WriteStreamHandle.php#L13) #### `Traits` -- [ReadHandleConvenienceMethodsTrait](./../../src/Psl/IO/ReadHandleConvenienceMethodsTrait.php#L15) -- [WriteHandleConvenienceMethodsTrait](./../../src/Psl/IO/WriteHandleConvenienceMethodsTrait.php#L16) +- [ReadHandleConvenienceMethodsTrait](./../../src/Psl/IO/ReadHandleConvenienceMethodsTrait.php#L16) +- [WriteHandleConvenienceMethodsTrait](./../../src/Psl/IO/WriteHandleConvenienceMethodsTrait.php#L17) diff --git a/docs/component/shell.md b/docs/component/shell.md index 364fdd49..90a27c32 100644 --- a/docs/component/shell.md +++ b/docs/component/shell.md @@ -12,7 +12,7 @@ #### `Functions` -- [execute](./../../src/Psl/Shell/execute.php#L41) +- [execute](./../../src/Psl/Shell/execute.php#L42) - [stream_unpack](./../../src/Psl/Shell/stream_unpack.php#L30) - [unpack](./../../src/Psl/Shell/unpack.php#L16) diff --git a/docs/component/tcp.md b/docs/component/tcp.md index 8c208bab..11694b10 100644 --- a/docs/component/tcp.md +++ b/docs/component/tcp.md @@ -12,7 +12,7 @@ #### `Functions` -- [connect](./../../src/Psl/TCP/connect.php#L18) +- [connect](./../../src/Psl/TCP/connect.php#L19) #### `Classes` diff --git a/docs/component/unix.md b/docs/component/unix.md index cec4b206..0b22b2bb 100644 --- a/docs/component/unix.md +++ b/docs/component/unix.md @@ -12,7 +12,7 @@ #### `Functions` -- [connect](./../../src/Psl/Unix/connect.php#L18) +- [connect](./../../src/Psl/Unix/connect.php#L19) #### `Classes` diff --git a/docs/documenter.php b/docs/documenter.php index 1fd92dd4..2e020ef1 100644 --- a/docs/documenter.php +++ b/docs/documenter.php @@ -191,6 +191,7 @@ function get_all_components(): array 'Psl\\Collection', 'Psl\\Comparison', 'Psl\\DataStructure', + 'Psl\\DateTime', 'Psl\\Dict', 'Psl\\Encoding\\Base64', 'Psl\\Encoding\\Hex', diff --git a/examples/async/usleep.php b/examples/async/usleep.php index 44b0dcad..dafcbebb 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 = DateTime\Timestamp::monotonic(); 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\Timestamp::monotonic()->since($start); - IO\write_error_line("duration: %d.", $duration); + IO\write_error_line("duration : %s.", $duration->toString(max_decimals: 5)); return 0; }); diff --git a/examples/channel/main.php b/examples/channel/main.php index 1d899555..2f5faa5c 100644 --- a/examples/channel/main.php +++ b/examples/channel/main.php @@ -6,6 +6,7 @@ use Psl\Async; use Psl\Channel; +use Psl\DateTime\Duration; use Psl\IO; require __DIR__ . '/../../vendor/autoload.php'; @@ -16,7 +17,7 @@ */ [$receiver, $sender] = Channel\unbounded(); -Async\Scheduler::delay(1, static function () use ($sender) { +Async\Scheduler::delay(Duration::seconds(1), static function () use ($sender) { $sender->send('Hello, World!'); }); diff --git a/examples/io/benchmark.php b/examples/io/benchmark.php index 24454a36..219b3828 100644 --- a/examples/io/benchmark.php +++ b/examples/io/benchmark.php @@ -5,13 +5,15 @@ namespace Psl\Example\IO; use Psl\Async; +use Psl\DateTime; use Psl\IO; +use Psl\Math; use Psl\Regex; + use function fopen; use function getopt; use function memory_get_peak_usage; -use function microtime; -use function round; + use const PHP_OS_FAMILY; require __DIR__ . '/../../vendor/autoload.php'; @@ -26,7 +28,7 @@ $args = getopt('i:o:t:'); $input_file = $args['i'] ?? '/dev/zero'; $output_file = $args['o'] ?? '/dev/null'; - $seconds = (int)($args['t'] ?? 5); + $seconds = DateTime\Duration::seconds((int)($args['t'] ?? 5)); // passing file descriptors requires mapping paths (https://bugs.php.net/bug.php?id=53465) $input_file = Regex\replace($input_file, '(^/dev/fd/)', 'php://fd/'); @@ -39,7 +41,7 @@ Async\Scheduler::delay($seconds, static fn() => $input->close()); - $start = microtime(true); + $start = DateTime\Timestamp::monotonic(); $i = 0; try { while ($chunk = $input->read(65536)) { @@ -51,12 +53,12 @@ } catch (IO\Exception\AlreadyClosedException) { } - $seconds = microtime(true) - $start; + $duration = DateTime\Timestamp::monotonic()->since($start); $bytes = $i * 65536; - $bytes_formatted = round($bytes / 1024 / 1024 / $seconds, 1); + $bytes_formatted = Math\round($bytes / 1024 / 1024 / $duration->getTotalSeconds(), 1); - IO\write_error_line('read %d byte(s) in %d second(s) => %dMiB/s', $bytes, round($seconds, 3), $bytes_formatted); - IO\write_error_line('peak memory usage of %dMiB', round(memory_get_peak_usage(true) / 1024 / 1024, 1)); + IO\write_error_line('read %d byte(s) in %s => %dMiB/s', $bytes, $duration->toString(), $bytes_formatted); + IO\write_error_line('peak memory usage of %dMiB', Math\round(memory_get_peak_usage(true) / 1024 / 1024, 1)); return 0; }); diff --git a/examples/io/pipe.php b/examples/io/pipe.php index 69f6d2bc..55e9b2c0 100644 --- a/examples/io/pipe.php +++ b/examples/io/pipe.php @@ -5,6 +5,7 @@ namespace Psl\Example\IO; use Psl\Async; +use Psl\DateTime\Duration; use Psl\IO; require __DIR__ . '/../../vendor/autoload.php'; @@ -16,7 +17,7 @@ static function() use($read): void { IO\write_error_line("< sleeping."); - Async\sleep(0.01); + Async\sleep(Duration::milliseconds(10)); IO\write_error_line("< waiting for content."); @@ -30,7 +31,7 @@ static function() use($read): void { static function() use($write): void { IO\write_error_line('> sleeping.'); - Async\sleep(0.1); + Async\sleep(Duration::milliseconds(100)); IO\write_error_line('> writing.'); diff --git a/examples/io/queued.php b/examples/io/queued.php index dc02ae57..ae344e77 100644 --- a/examples/io/queued.php +++ b/examples/io/queued.php @@ -15,7 +15,7 @@ $he = Async\run(static fn(): string => $read->readFixedSize(2)); - Async\sleep(0.001); + Async\sleep(Psl\DateTime\Duration::milliseconds(200)); $write->write("hello"); diff --git a/examples/run.php b/examples/run.php index 45b60756..d2af7dbb 100644 --- a/examples/run.php +++ b/examples/run.php @@ -6,6 +6,7 @@ namespace Psl\Example\IO; use Psl\Async; +use Psl\DateTime; use Psl\Filesystem; use Psl\IO; use Psl\Shell; @@ -34,9 +35,9 @@ IO\write_error_line('- %s/%s -> started', $component, $script); $awaitables[] = Async\run(static function() use($component, $script, $file): array { - $start = microtime(true); + $start = DateTime\Timestamp::monotonic(); Shell\execute(PHP_BINARY, [$file]); - $duration = microtime(true) - $start; + $duration = DateTime\Timestamp::monotonic()->since($start); return [$component, $script, $duration]; }); @@ -46,7 +47,7 @@ foreach (Async\Awaitable::iterate($awaitables) as $awaitable) { [$component, $script, $duration] = $awaitable->await(); - IO\write_error_line('+ %s/%s -> finished in %ds', $component, $script, $duration); + IO\write_error_line('+ %s/%s -> finished in %s', $component, $script, $duration->toString()); } return 0; diff --git a/examples/shell/timeout.php b/examples/shell/timeout.php index 824068cc..8a9ebe68 100644 --- a/examples/shell/timeout.php +++ b/examples/shell/timeout.php @@ -5,6 +5,7 @@ namespace Psl\Example\Shell; use Psl\Async; +use Psl\DateTime; use Psl\IO; use Psl\Shell; @@ -12,7 +13,7 @@ Async\main(static function (): void { try { - Shell\execute('sleep', ['1'], timeout: 0.5); + Shell\execute('sleep', ['1'], timeout: DateTime\Duration::milliseconds(500)); } catch (Shell\Exception\TimeoutException $exception) { IO\write_error_line($exception->getMessage()); } diff --git a/src/Psl/Async/OptionalIncrementalTimeout.php b/src/Psl/Async/OptionalIncrementalTimeout.php index 4aa89451..78b511aa 100644 --- a/src/Psl/Async/OptionalIncrementalTimeout.php +++ b/src/Psl/Async/OptionalIncrementalTimeout.php @@ -5,8 +5,8 @@ namespace Psl\Async; use Closure; - -use function microtime; +use Psl\DateTime\Duration; +use Psl\DateTime\Timestamp; /** * Manages optional incremental timeouts for asynchronous operations. @@ -16,28 +16,40 @@ * particularly useful in asynchronous programming where operations * might need to be interrupted or handled differently if they take * too long to complete. + * + * @psalm-suppress MissingThrowsDocblock */ final class OptionalIncrementalTimeout { /** - * @var ?float The end time in microseconds. + * @var ?Timestamp The end time. */ - private ?float $end; + private ?Timestamp $end; /** - * @var (Closure(): ?float) The handler to be called upon timeout. + * @var (Closure(): ?Duration) The handler to be called upon timeout. */ private Closure $handler; /** - * @param float|null $timeout The timeout duration in seconds. Null to disable timeout. - * @param (Closure(): ?float) $handler The handler to be executed if the timeout is reached. + * @param null|Duration $timeout The timeout duration. Null to disable timeout. + * @param (Closure(): ?Duration) $handler The handler to be executed if the timeout is reached. */ - public function __construct(?float $timeout, Closure $handler) + public function __construct(?Duration $timeout, Closure $handler) { $this->handler = $handler; - $this->end = $timeout !== null ? (microtime(true) + $timeout) : null; + if (null === $timeout) { + $this->end = null; + + return; + } + + if (!$timeout->isPositive()) { + $this->end = Timestamp::monotonic(); + } else { + $this->end = Timestamp::monotonic()->plus($timeout); + } } /** @@ -45,16 +57,16 @@ public function __construct(?float $timeout, Closure $handler) * * If the timeout has already been exceeded, the handler is invoked, and its return value is provided. * - * @return float|null The remaining time in seconds, null if no timeout is set, or the handler's return value if the timeout is exceeded. + * @return Duration|null The remaining time duration, null if no timeout is set, or the handler's return value if the timeout is exceeded. */ - public function getRemaining(): ?float + public function getRemaining(): ?Duration { if ($this->end === null) { return null; } - $remaining = $this->end - microtime(true); + $remaining = $this->end->since(Timestamp::monotonic()); - return $remaining <= 0 ? ($this->handler)() : $remaining; + return $remaining->isPositive() ? $remaining : ($this->handler)(); } } diff --git a/src/Psl/Async/Scheduler.php b/src/Psl/Async/Scheduler.php index 75c79dad..e499afbb 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,35 +115,36 @@ 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 $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 $delay, Closure $callback): string { /** @var non-empty-string */ - return EventLoop::delay($delay, $callback); + return EventLoop::delay($delay->getTotalSeconds(), $callback); } /** * Repeatedly execute a callback. * - * @param float $interval The time interval, to wait between executions in seconds. + * @param DateTime\Duration $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 $interval, Closure $callback): string { /** @var non-empty-string */ - return EventLoop::repeat($interval, $callback); + return EventLoop::repeat($interval->getTotalSeconds(), $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..394ab5c7 100644 --- a/src/Psl/Async/sleep.php +++ b/src/Psl/Async/sleep.php @@ -4,15 +4,19 @@ 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 $duration): void { $suspension = EventLoop::getSuspension(); - $watcher = EventLoop::delay($seconds, static fn () => $suspension->resume()); + $watcher = EventLoop::delay( + $duration->getTotalSeconds(), + static fn () => $suspension->resume(), + ); try { $suspension->suspend(); diff --git a/src/Psl/DateTime/DateTime.php b/src/Psl/DateTime/DateTime.php new file mode 100644 index 00000000..a9f08f8c --- /dev/null +++ b/src/Psl/DateTime/DateTime.php @@ -0,0 +1,552 @@ + + */ + 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. + * + * @psalm-mutation-free + */ + 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) { + throw Exception\InvalidArgumentException::forNanoseconds($nanoseconds); + } + + if ($seconds < 0 || $seconds >= 60) { + throw Exception\InvalidArgumentException::forSeconds($seconds); + } + + if ($minutes < 0 || $minutes >= 60) { + throw Exception\InvalidArgumentException::forMinutes($minutes); + } + + if ($hours < 0 || $hours >= 24) { + throw Exception\InvalidArgumentException::forHours($hours); + } + + if ($month < 1 || $month > 12) { + throw Exception\InvalidArgumentException::forMonth($month); + } + + if ($day < 1 || $day > 31 || $day > Month::from($month)->getDaysForYear($year)) { + throw Exception\InvalidArgumentException::forDay($day, $month, $year); + } + + $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 {@see DateTime} instance representing the current moment. + * + * This static method returns a {@see DateTime} object set to the current date and time. If a specific timezone is + * provided, the returned {@see DateTime} will be adjusted to reflect the date and time in that timezone. + * + * @param null|Timezone $timezone Optional timezone. If null, uses the system's default timezone. + * + * @psalm-mutation-free + */ + public static function now(?Timezone $timezone = null): DateTime + { + return self::fromTimestamp(Timestamp::now(), $timezone); + } + + /** + * 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. + * + * 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\UnexpectedValueException If any of the provided time components do not align with calendar expectations. + * + * @psalm-mutation-free + */ + 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 using the specified year, month, day, hour, minute, second, + * and nanosecond components within a given timezone. It validates each component against the Gregorian calendar + * to ensure the date and time are possible. For example, it checks for the correct range of months (1-12), + * days in a month (considering leap years), hours (0-23), minutes (0-59), and seconds (0-59). + * + * Note: 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\UnexpectedValueException If any of the provided date or time components do not align with calendar expectations. + * + * @pure + * + * @psalm-suppress ImpureMethodCall + */ + public static function fromParts(Timezone $timezone, int $year, Month|int $month, int $day, int $hours = 0, int $minutes = 0, int $seconds = 0, int $nanoseconds = 0): self + { + if ($month instanceof Month) { + $month = $month->value; + } + + /** + * @var IntlCalendar $calendar + */ + $calendar = IntlCalendar::createInstance( + Internal\to_intl_timezone($timezone), + ); + + $calendar->set($year, $month - 1, $day, $hours, $minutes, $seconds); + + if ($seconds !== $calendar->get(IntlCalendar::FIELD_SECOND)) { + throw Exception\UnexpectedValueException::forSeconds($seconds, $calendar->get(IntlCalendar::FIELD_SECOND)); + } + + if ($minutes !== $calendar->get(IntlCalendar::FIELD_MINUTE)) { + throw Exception\UnexpectedValueException::forMinutes($minutes, $calendar->get(IntlCalendar::FIELD_MINUTE)); + } + + if ($hours !== $calendar->get(IntlCalendar::FIELD_HOUR_OF_DAY)) { + throw Exception\UnexpectedValueException::forHours($hours, $calendar->get(IntlCalendar::FIELD_HOUR_OF_DAY)); + } + + if ($day !== $calendar->get(IntlCalendar::FIELD_DAY_OF_MONTH)) { + throw Exception\UnexpectedValueException::forDay($day, $calendar->get(IntlCalendar::FIELD_DAY_OF_MONTH)); + } + + if ($month !== ($calendar->get(IntlCalendar::FIELD_MONTH) + 1)) { + throw Exception\UnexpectedValueException::forMonth($month, $calendar->get(IntlCalendar::FIELD_MONTH) + 1); + } + + if ($year !== $calendar->get(IntlCalendar::FIELD_YEAR)) { + throw Exception\UnexpectedValueException::forYear($year, $calendar->get(IntlCalendar::FIELD_YEAR)); + } + + $timestamp_in_seconds = (int) ($calendar->getTime() / ((float) MILLISECONDS_PER_SECOND)); + /** @psalm-suppress MissingThrowsDocblock */ + $timestamp = Timestamp::fromParts($timestamp_in_seconds, $nanoseconds); + + /** @psalm-suppress MissingThrowsDocblock */ + return new self( + $timezone, + $timestamp, + $year, + $month, + $day, + $hours, + $minutes, + $seconds, + $nanoseconds + ); + } + + /** + * 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. + * + * @param null|Timezone $timezone Optional timezone. If null, uses the system's default timezone. + * + * @see Timezone::default() + * + * @psalm-mutation-free + * + * @psalm-suppress ImpureMethodCall + */ + public static function fromTimestamp(Timestamp $timestamp, ?Timezone $timezone = null): static + { + $timezone ??= Timezone::default(); + + /** @var IntlCalendar $calendar */ + $calendar = IntlCalendar::createInstance( + Internal\to_intl_timezone($timezone), + ); + + $calendar->setTime( + $timestamp->getSeconds() * MILLISECONDS_PER_SECOND, + ); + + $year = $calendar->get(IntlCalendar::FIELD_YEAR); + $month = $calendar->get(IntlCalendar::FIELD_MONTH) + 1; + $day = $calendar->get(IntlCalendar::FIELD_DAY_OF_MONTH); + $hour = $calendar->get(IntlCalendar::FIELD_HOUR_OF_DAY); + $minute = $calendar->get(IntlCalendar::FIELD_MINUTE); + $second = $calendar->get(IntlCalendar::FIELD_SECOND); + $nanoseconds = $timestamp->getNanoseconds(); + + /** @psalm-suppress MissingThrowsDocblock */ + return new static( + $timezone, + $timestamp, + $year, + $month, + $day, + $hour, + $minute, + $second, + $nanoseconds, + ); + } + + + /** + * Parses a date and time string into an instance of {@see Timestamp} using a specific format pattern, with optional customization for timezone and locale. + * + * This method is specifically designed for cases where a custom format pattern is used to parse the input string. + * + * It allows for precise control over the parsing process by specifying the exact format pattern that matches the input string. + * + * Additionally, the method supports specifying a timezone and locale for parsing, enabling accurate interpretation of locale-specific formats. + * + * Example usage: + * + * ```php + * $raw_string = '2023-03-15 12:00:00'; + * $parsed_datetime = DateTime\DateTime::parse($raw_string, 'yyyy-MM-dd HH:mm:ss', DateTime\Timezone::Utc, Locale\Locale::English); + * ``` + * + * @param string $raw_string The date and time string to parse. + * @param null|FormatPattern|string $pattern The custom format pattern for parsing the date and time. If null, uses a default pattern. + * @param null|Timezone $timezone Optional timezone for parsing. If null, uses the system's default timezone. + * @param null|Locale $locale Optional locale for parsing. If null, uses the system's default locale. + * + * @throws Exception\RuntimeException If the parsing process fails. + * + * @return static Returns an instance of {@see Timestamp} representing the parsed date and time. + * + * @see https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax + * @see TemporalInterface::format() + * + * @psalm-mutation-free + */ + public static function parse(string $raw_string, null|FormatPattern|string $pattern = null, ?Timezone $timezone = null, null|Locale $locale = null): static + { + $timezone ??= Timezone::default(); + + return self::fromTimestamp(Timestamp::parse($raw_string, $pattern, $timezone, $locale), $timezone); + } + + /** + * Creates an instance of {@see DateTime} from a date and time string, formatted according to specified styles for date and time, + * with optional customization for timezone and locale. + * + * This method provides a more abstracted approach to parsing, allowing users to specify styles rather than a custom pattern. + * + * This is particularly useful for parsing strings that follow common date and time formats. + * + * Additionally, the timezone and locale parameters enable accurate parsing of strings in locale-specific formats. + * + * Example usage: + * + * ```php + * $raw_string = "March 15, 2023, 12:00 PM"; + * + * $datetime = DateTime\DateTime::fromString($raw_string, FormatDateStyle::Long, FormatTimeStyle::Short, DateTime\Timezone::Utc, Locale\Locale::English); + * ``` + * + * @param string $raw_string The date and time string to parse. + * @param null|FormatDateStyle $date_style The style for the date portion of the string. If null, a default style is used. + * @param null|FormatTimeStyle $time_style The style for the time portion of the string. If null, a default style is used. + * @param null|Timezone $timezone Optional timezone for parsing. If null, uses the system's default timezone. + * @param null|Locale $locale Optional locale for parsing. If null, uses the system's default locale. + * + * @throws Exception\RuntimeException If the parsing process fails. + * + * @return static Returns an instance of {@see DateTime} representing the parsed date and time. + * + * @see DateTimeInterface::toString() + * + * @psalm-mutation-free + */ + public static function fromString(string $raw_string, null|FormatDateStyle $date_style = null, null|FormatTimeStyle $time_style = null, null|Timezone $timezone = null, null|Locale $locale = null): static + { + $timezone ??= Timezone::default(); + + return self::fromTimestamp(Timestamp::fromString($raw_string, $date_style, $time_style, $timezone, $locale), $timezone); + } + + /** + * Returns the timestamp representation of this date time object. + * + * @psalm-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. + * + * @psalm-mutation-free + */ + public function getYear(): int + { + return $this->year; + } + + /** + * Returns the month. + * + * @return int<1, 12> + * + * @psalm-mutation-free + */ + public function getMonth(): int + { + return $this->month; + } + + /** + * Returns the day. + * + * @return int<1, 31> + * + * @psalm-mutation-free + */ + public function getDay(): int + { + return $this->day; + } + + /** + * Returns the hours. + * + * @return int<0, 23> + * + * @psalm-mutation-free + */ + public function getHours(): int + { + return $this->hours; + } + + /** + * Returns the minutes. + * + * @return int<0, 59> + * + * @psalm-mutation-free + */ + public function getMinutes(): int + { + return $this->minutes; + } + + /** + * Returns the seconds. + * + * @return int<0, 59> + * + * @psalm-mutation-free + */ + public function getSeconds(): int + { + return $this->seconds; + } + + /** + * Returns the nanoseconds. + * + * @return int<0, 999999999> + * + * @psalm-mutation-free + */ + public function getNanoseconds(): int + { + return $this->nanoseconds; + } + + /** + * Gets the timezone associated with the date and time. + * + * @psalm-mutation-free + */ + public function getTimezone(): Timezone + { + return $this->timezone; + } + + /** + * Returns a new instance with the specified date. + * + * @param Month|int<1, 12> $month + * @param int<1, 31> $day + * + * @throws Exception\UnexpectedValueException If any of the provided date components do not align with calendar expectations. + * + * @psalm-mutation-free + */ + public function withDate(int $year, Month|int $month, int $day): static + { + return static::fromParts( + $this->getTimezone(), + $year, + $month, + $day, + $this->getHours(), + $this->getMinutes(), + $this->getSeconds(), + $this->getNanoseconds(), + ); + } + + /** + * 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\UnexpectedValueException If any of the provided time components do not align with calendar expectations. + * + * @psalm-mutation-free + */ + public function withTime(int $hours, int $minutes, int $seconds = 0, int $nanoseconds = 0): static + { + return static::fromParts( + $this->getTimezone(), + $this->getYear(), + $this->getMonth(), + $this->getDay(), + $hours, + $minutes, + $seconds, + $nanoseconds, + ); + } + + /** + * Adds the specified duration to this date-time 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. + * + * @psalm-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\UnderflowException If subtracting the duration results in an arithmetic underflow. + * @throws Exception\OverflowException If subtracting the duration results in an arithmetic overflow. + * + * @psalm-mutation-free + */ + public function minus(Duration $duration): static + { + return static::fromTimestamp($this->getTimestamp()->minus($duration), $this->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..b69b3634 --- /dev/null +++ b/src/Psl/DateTime/DateTimeConvenienceMethodsTrait.php @@ -0,0 +1,561 @@ +equals($other) && $this->getTimezone() === $other->getTimezone(); + } + + /** + * Obtains the timezone offset as a {@see Duration} object. + * + * This method effectively returns the offset from UTC for the timezone of this instance at the specific date and time it represents. + * + * It is equivalent to executing `$dt->getTimezone()->getOffset($dt)`, which calculates the offset for the timezone of this instance. + * + * @return Duration The offset from UTC as a Duration. + * + * @psalm-mutation-free + */ + public function getTimezoneOffset(): Duration + { + return $this->getTimezone()->getOffset($this); + } + + /** + * Determines whether this instance is currently in daylight saving time. + * + * This method checks if the date and time represented by this instance fall within the daylight saving time period of its timezone. + * + * It is equivalent to `!$dt->getTimezone()->getDaylightSavingTimeOffset($dt)->isZero()`, indicating whether there is a non-zero DST offset. + * + * @return bool True if in daylight saving time, false otherwise. + * + * @psalm-mutation-free + */ + public function isDaylightSavingTime(): bool + { + return !$this->getTimezone()->getDaylightSavingTimeOffset($this)->isZero(); + } + + /** + * Converts the {@see DateTimeInterface} instance to the specified timezone. + * + * @param Timezone $timezone The timezone to convert to. + * + * @psalm-mutation-free + */ + public function convertToTimezone(Timezone $timezone): static + { + return static::fromTimestamp($this->getTimestamp(), $timezone); + } + + /** + * Returns a new instance with the specified year. + * + * @throws Exception\UnexpectedValueException If the provided year do not align with calendar expectations. + * + * @psalm-mutation-free + */ + public function withYear(int $year): static + { + return $this->withDate($year, $this->getMonth(), $this->getDay()); + } + + /** + * Returns a new instance with the specified month. + * + * @param Month|int<1, 12> $month + * + * @throws Exception\UnexpectedValueException If the provided month do not align with calendar expectations. + * + * @psalm-mutation-free + */ + public function withMonth(Month|int $month): static + { + return $this->withDate($this->getYear(), $month, $this->getDay()); + } + + /** + * Returns a new instance with the specified day. + * + * @param int<1, 31> $day + * + * @throws Exception\UnexpectedValueException If the provided day do not align with calendar expectations. + * + * @psalm-mutation-free + */ + public function withDay(int $day): static + { + return $this->withDate($this->getYear(), $this->getMonth(), $day); + } + + /** + * Returns a new instance with the specified hours. + * + * @param int<0, 23> $hours + * + * @throws Exception\UnexpectedValueException If the provided hours do not align with calendar expectations. + * + * @psalm-mutation-free + */ + public function withHours(int $hours): static + { + return $this->withTime($hours, $this->getMinutes(), $this->getSeconds(), $this->getNanoseconds()); + } + + /** + * Returns a new instance with the specified minutes. + * + * @param int<0, 59> $minutes + * + * @throws Exception\UnexpectedValueException If the provided minutes do not align with calendar expectations. + * + * @psalm-mutation-free + */ + public function withMinutes(int $minutes): static + { + return $this->withTime($this->getHours(), $minutes, $this->getSeconds(), $this->getNanoseconds()); + } + + /** + * Returns a new instance with the specified seconds. + * + * @param int<0, 59> $seconds + * + * @throws Exception\UnexpectedValueException If the provided seconds do not align with calendar expectations. + * + * @psalm-mutation-free + */ + public function withSeconds(int $seconds): static + { + return $this->withTime($this->getHours(), $this->getMinutes(), $seconds, $this->getNanoseconds()); + } + + /** + * Returns a new instance with the specified nanoseconds. + * + * @param int<0, 999999999> $nanoseconds + * + * @throws Exception\UnexpectedValueException If the provided nanoseconds do not align with calendar expectations. + * + * @psalm-mutation-free + */ + public function withNanoseconds(int $nanoseconds): static + { + return $this->withTime($this->getHours(), $this->getMinutes(), $this->getSeconds(), $nanoseconds); + } + + /** + * Returns the date (year, month, day). + * + * @return array{int, int<1, 12>, int<1, 31>} The date. + * + * @psalm-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>, + * } + * + * @psalm-mutation-free + */ + public function getTime(): array + { + return [ + $this->getHours(), + $this->getMinutes(), + $this->getSeconds(), + $this->getNanoseconds(), + ]; + } + + /** + * Returns the {@see DateTimeInterface} 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>, + * } + * + * @psalm-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. + * + * @psalm-mutation-free + */ + public function getEra(): Era + { + return Era::fromYear($this->getYear()); + } + + /** + * Returns the century number for the year stored in this object. + * + * @psalm-mutation-free + */ + public function getCentury(): int + { + return (int)($this->getYear() / 100) + 1; + } + + /** + * Returns the short format of the year (last 2 digits). + * + * @return int<-99, 99> The short format of the year. + * + * @psalm-mutation-free + */ + public function getYearShort(): int + { + /** @var int<-99, 99> */ + return (int) $this->format(pattern: 'yy'); + } + + /** + * Returns the month as an instance of the {@see Month} enum. + * + * This method converts the numeric representation of the month into its corresponding + * case in the {@see Month} enum, providing a type-safe way to work with months. + * + * @return Month The month as an enum case. + * + * @psalm-mutation-free + */ + public function getMonthEnum(): Month + { + return Month::from($this->getMonth()); + } + + /** + * 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. + * + * @psalm-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>} + * + * @psalm-mutation-free + */ + public function getISOWeekNumber(): array + { + /** @var int<1, 53> $week */ + $week = (int)$this->format(pattern: 'w'); + $year = (int)$this->format(pattern: 'Y'); + + return [$year, $week]; + } + + /** + * Gets the weekday of the date. + * + * @return Weekday The weekday. + * + * @psalm-mutation-free + */ + public function getWeekday(): Weekday + { + return Weekday::from((int) $this->format(pattern: 'e')); + } + + /** + * Checks if the year is a leap year. + * + * @psalm-mutation-free + */ + public function isLeapYear(): bool + { + return namespace\is_leap_year($this->getYear()); + } + + /** + * 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. + * + * @psalm-mutation-free + */ + public function plusYears(int $years): static + { + return $this->plusMonths($years * MONTHS_PER_YEAR); + } + + /** + * 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. + * + * @psalm-mutation-free + */ + public function minusYears(int $years): static + { + return $this->minusMonths($years * MONTHS_PER_YEAR); + } + + /** + * 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. + * + * @psalm-mutation-free + */ + public function plusMonths(int $months): static + { + if (0 === $months) { + return $this; + } + + if (0 > $months) { + return $this->minusMonths(-$months); + } + + $current_year = $this->getYear(); + $current_month = $this->getMonthEnum(); + $days_to_add = 0; + for ($i = 0; $i < $months; $i++) { + $total_months = $current_month->value + $i; + $target_year = $current_year + Math\div($total_months - 1, 12); + $target_month = $total_months % 12; + if ($target_month === 0) { + $target_month = 1; + } + + $days_to_add += Month::from($target_month)->getDaysForYear($target_year); + } + + return $this->plus(Duration::days($days_to_add)); + } + + /** + * 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. + * + * @psalm-mutation-free + */ + public function minusMonths(int $months): static + { + if (0 === $months) { + return $this; + } + + if (0 > $months) { + return $this->plusMonths(-$months); + } + + $current_year = $this->getYear(); + $current_month = $this->getMonthEnum(); + $days_to_subtract = 0; + for ($i = 0; $i < $months; $i++) { + // When subtracting, we need to move the current month back before the calculation + $total_months = $current_month->value - $i - 1; + if ($total_months <= 0) { + $total_months += 12; // Adjust month to be within 1-12 + $current_year--; // Adjust year when wrapping + } + + $target_month = ($total_months % 12) ?: 12; + $target_year = $current_year + Math\div($total_months - 1, 12); + + // Subtract days of the month we are moving into + $days_to_subtract += Month::from($target_month)->getDaysForYear($target_year); + } + + return $this->minus(Duration::days($days_to_subtract)); + } + + /** + * 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. + * + * @psalm-mutation-free + */ + public function plusDays(int $days): static + { + return $this->plus(Duration::days($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. + * + * @psalm-mutation-free + */ + public function minusDays(int $days): static + { + return $this->minus(Duration::days($days)); + } + + /** + * Formats this {@see DateTimeInterface} instance based on a specific pattern, with optional customization for timezone and locale. + * + * This method allows for detailed customization of the output string by specifying a format pattern. If no pattern is provided, + * a default, implementation-specific pattern will be used. Additionally, the method supports specifying a timezone and locale + * for further customization of the formatted output. If these are not provided, system defaults will be used. + * + * Example usage: + * + * ```php + * $formatted = $temporal->format('yyyy-MM-dd HH:mm:ss', $timezone, $locale); + * ``` + * + * @param null|FormatPattern|string $pattern Optional custom format pattern for the date and time. If null, uses a default pattern. + * @param null|Timezone $timezone Optional timezone for formatting. If null, uses the current timezone. + * @param null|Locale $locale Optional locale for formatting. If null, uses the system's default locale. + * + * @return string The formatted date and time string, according to the specified pattern, timezone, and locale. + * + * @see https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax + * @see Locale::default() + * + * @psalm-mutation-free + */ + public function format(null|FormatPattern|string $pattern = null, null|Timezone $timezone = null, null|Locale $locale = null): string + { + $timestamp = $this->getTimestamp(); + + /** + * @psalm-suppress InvalidOperand + * @psalm-suppress ImpureMethodCall + */ + return Internal\create_intl_date_formatter(null, null, $pattern, $timezone ?? $this->getTimezone(), $locale) + ->format($timestamp->getSeconds() + ($timestamp->getNanoseconds() / NANOSECONDS_PER_SECOND)); + } + + /** + * Provides a string representation of this {@see TemporalInterface} instance, formatted according to specified styles for date and time, + * and optionally adjusted for a specific timezone and locale. + * + * This method offers a higher-level abstraction for formatting, allowing users to specify styles for date and time separately + * rather than a custom pattern. If no styles are provided, default styles will be used. + * + * Additionally, the timezone and locale can be specified for locale-sensitive formatting. + * + * Example usage: + * + * ```php + * $string_representation = $temporal->toString(FormatDateStyle::Long, FormatTimeStyle::Short, $timezone, $locale); + * ``` + * + * @param null|FormatDateStyle $date_style Optional style for the date portion of the output. If null, a default style is used. + * @param null|FormatTimeStyle $time_style Optional style for the time portion of the output. If null, a default style is used. + * @param null|Timezone $timezone Optional timezone for formatting. If null, uses the current timezone. + * @param null|Locale $locale Optional locale for formatting. If null, uses the system's default locale. + * + * @return string The string representation of the date and time, formatted according to the specified styles, timezone, and locale. + * + * @see FormatDateStyle::default() + * @see FormatTimeStyle::default() + * @see Locale::default() + * + * @psalm-mutation-free + */ + public function toString(null|FormatDateStyle $date_style = null, null|FormatTimeStyle $time_style = null, null|Timezone $timezone = null, null|Locale $locale = null): string + { + $timestamp = $this->getTimestamp(); + + /** + * @psalm-suppress InvalidOperand + * @psalm-suppress ImpureMethodCall + */ + return Internal\create_intl_date_formatter($date_style, $time_style, null, $timezone ?? $this->getTimezone(), $locale) + ->format($timestamp->getSeconds() + ($timestamp->getNanoseconds() / NANOSECONDS_PER_SECOND)); + } +} diff --git a/src/Psl/DateTime/DateTimeInterface.php b/src/Psl/DateTime/DateTimeInterface.php new file mode 100644 index 00000000..037b789a --- /dev/null +++ b/src/Psl/DateTime/DateTimeInterface.php @@ -0,0 +1,516 @@ +getTimezone()->getOffset($dt)`, which calculates the offset for the timezone of this instance. + * + * @return Duration The offset from UTC as a Duration. + * + * @psalm-mutation-free + */ + public function getTimezoneOffset(): Duration; + + /** + * Determines whether this instance is currently in daylight saving time. + * + * This method checks if the date and time represented by this instance fall within the daylight saving time period of its timezone. + * + * It is equivalent to `!$dt->getTimezone()->getDaylightSavingTimeOffset($dt)->isZero()`, indicating whether there is a non-zero DST offset. + * + * @return bool True if in daylight saving time, false otherwise. + * + * @psalm-mutation-free + */ + public function isDaylightSavingTime(): bool; + + /** + * Returns a new instance with the specified date. + * + * @param Month|int<1, 12> $month + * @param int<1, 31> $day + * + * @throws Exception\UnexpectedValueException If any of the provided date components do not align with calendar expectations. + * + * @psalm-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\UnexpectedValueException If any of the provided time components do not align with calendar expectations. + * + * @psalm-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\UnexpectedValueException If the provided year do not align with calendar expectations. + * + * @psalm-mutation-free + */ + public function withYear(int $year): static; + + /** + * Returns a new instance with the specified month. + * + * @param Month|int<1, 12> $month + * + * @throws Exception\UnexpectedValueException If the provided month do not align with calendar expectations. + * + * @psalm-mutation-free + */ + public function withMonth(Month|int $month): static; + + /** + * Returns a new instance with the specified day. + * + * @param int<1, 31> $day + * + * @throws Exception\UnexpectedValueException If the provided day do not align with calendar expectations. + * + * @psalm-mutation-free + */ + public function withDay(int $day): static; + + /** + * Returns a new instance with the specified hours. + * + * @param int<0, 23> $hours + * + * @throws Exception\UnexpectedValueException If the provided hours do not align with calendar expectations. + * + * @psalm-mutation-free + */ + public function withHours(int $hours): static; + + /** + * Returns a new instance with the specified minutes. + * + * @param int<0, 59> $minutes + * + * @throws Exception\UnexpectedValueException If the provided minutes do not align with calendar expectations. + * + * @psalm-mutation-free + */ + public function withMinutes(int $minutes): static; + + /** + * Returns a new instance with the specified seconds. + * + * @param int<0, 59> $seconds + * + * @throws Exception\UnexpectedValueException If the provided seconds do not align with calendar expectations. + * + * @psalm-mutation-free + */ + public function withSeconds(int $seconds): static; + + /** + * Returns a new instance with the specified nanoseconds. + * + * @param int<0, 999999999> $nanoseconds + * + * @throws Exception\UnexpectedValueException If the provided nanoseconds do not align with calendar expectations. + * + * @psalm-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. + * + * @psalm-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>, + * } + * + * @psalm-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>, + * } + * + * @psalm-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. + * + * @psalm-mutation-free + */ + public function getEra(): Era; + + /** + * Returns the century number for the year stored in this object. + * + * @psalm-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. + * + * @psalm-mutation-free + */ + public function getYear(): int; + + /** + * Returns the short format of the year (last 2 digits). + * + * @return int<-99, 99> The short format of the year. + * + * @psalm-mutation-free + */ + public function getYearShort(): int; + + /** + * Returns the month. + * + * @return int<1, 12> + * + * @psalm-mutation-free + */ + public function getMonth(): int; + + /** + * Returns the month as an instance of the {@see Month} enum. + * + * This method converts the numeric representation of the month into its corresponding + * case in the {@see Month} enum, providing a type-safe way to work with months. + * + * @return Month The month as an enum case. + * + * @psalm-mutation-free + */ + public function getMonthEnum(): Month; + + /** + * Returns the day. + * + * @return int<0, 31> + * + * @psalm-mutation-free + */ + public function getDay(): int; + + /** + * Returns the hours. + * + * @return int<0, 23> + * + * @psalm-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. + * + * @psalm-mutation-free + */ + public function getTwelveHours(): array; + + /** + * Returns the minutes. + * + * @return int<0, 59> + * + * @psalm-mutation-free + */ + public function getMinutes(): int; + + /** + * Returns the seconds. + * + * @return int<0, 59> + * + * @psalm-mutation-free + */ + public function getSeconds(): int; + + /** + * Returns the nanoseconds. + * + * @return int<0, 999999999> + * + * @psalm-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>} + * + * @psalm-mutation-free + */ + public function getISOWeekNumber(): array; + + /** + * Gets the weekday of the date. + * + * @return Weekday The weekday. + * + * @psalm-mutation-free + */ + public function getWeekday(): Weekday; + + /** + * Checks if the year is a leap year. + * + * @psalm-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. + * + * @psalm-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. + * + * @psalm-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. + * + * @psalm-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. + * + * @psalm-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. + * + * @psalm-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. + * + * @psalm-mutation-free + */ + public function minusDays(int $days): static; + + /** + * Formats this {@see DateTimeInterface} instance based on a specific pattern, with optional customization for timezone and locale. + * + * This method allows for detailed customization of the output string by specifying a format pattern. If no pattern is provided, + * a default, implementation-specific pattern will be used. Additionally, the method supports specifying a timezone and locale + * for further customization of the formatted output. If these are not provided, system defaults will be used. + * + * Example usage: + * + * ```php + * $formatted = $temporal->format('yyyy-MM-dd HH:mm:ss', $timezone, $locale); + * ``` + * + * @param null|FormatPattern|string $pattern Optional custom format pattern for the date and time. If null, uses a default pattern. + * @param null|Timezone $timezone Optional timezone for formatting. If null, uses the current timezone. + * @param null|Locale $locale Optional locale for formatting. If null, uses the system's default locale. + * + * @return string The formatted date and time string, according to the specified pattern, timezone, and locale. + * + * @see https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax + * @see Locale::default() + * + * @psalm-mutation-free + */ + public function format(null|FormatPattern|string $pattern = null, null|Timezone $timezone = null, null|Locale $locale = null): string; + + /** + * Provides a string representation of this {@see TemporalInterface} instance, formatted according to specified styles for date and time, + * and optionally adjusted for a specific timezone and locale. + * + * This method offers a higher-level abstraction for formatting, allowing users to specify styles for date and time separately + * rather than a custom pattern. If no styles are provided, default styles will be used. + * + * Additionally, the timezone and locale can be specified for locale-sensitive formatting. + * + * Example usage: + * + * ```php + * $string_representation = $temporal->toString(FormatDateStyle::Long, FormatTimeStyle::Short, $timezone, $locale); + * ``` + * + * @param null|FormatDateStyle $date_style Optional style for the date portion of the output. If null, a default style is used. + * @param null|FormatTimeStyle $time_style Optional style for the time portion of the output. If null, a default style is used. + * @param null|Timezone $timezone Optional timezone for formatting. If null, uses the current timezone. + * @param null|Locale $locale Optional locale for formatting. If null, uses the system's default locale. + * + * @return string The string representation of the date and time, formatted according to the specified styles, timezone, and locale. + * + * @see FormatDateStyle::default() + * @see FormatTimeStyle::default() + * @see Locale::default() + * + * @psalm-mutation-free + */ + public function toString(null|FormatDateStyle $date_style = null, null|FormatTimeStyle $time_style = null, null|Timezone $timezone = null, null|Locale $locale = null): string; + + /** + * Magic method that provides a default string representation of the date and time. + * + * This method is a shortcut for calling `toString()` with all null arguments, returning a string formatted + * with default styles, timezone, and locale. It is automatically called when the object is used in a string context. + * + * Example usage: + * + * ```php + * $default_string_representation = (string) $temporal; // Uses __toString() for formatting + * ``` + * + * @return string The default string representation of the date and time. + * + * @see TemporalInterface::toString() + * + * @psalm-mutation-free + */ + public function __toString(): string; + + /** + * Converts the {@see DateTimeInterface} instance to the specified timezone. + * + * @param Timezone $timezone The timezone to convert to. + * + * @psalm-mutation-free + */ + public function convertToTimezone(Timezone $timezone): static; +} diff --git a/src/Psl/DateTime/Duration.php b/src/Psl/DateTime/Duration.php new file mode 100644 index 00000000..e5dd7270 --- /dev/null +++ b/src/Psl/DateTime/Duration.php @@ -0,0 +1,729 @@ + + * @implements Comparison\Equable + * + * @immutable + */ +final readonly 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 + * + * @pure + */ + private function __construct( + private int $hours, + private int $minutes, + private int $seconds, + private 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} + * + * @psalm-mutation-free + */ + public function getParts(): array + { + return [$this->hours, $this->minutes, $this->seconds, $this->nanoseconds]; + } + + /** + * Returns the "hours" part of this time duration. + * + * @psalm-mutation-free + */ + public function getHours(): int + { + return $this->hours; + } + + /** + * Returns the "minutes" part of this time duration. + * + * @psalm-mutation-free + */ + public function getMinutes(): int + { + return $this->minutes; + } + + /** + * Returns the "seconds" part of this time duration. + * + * @psalm-mutation-free + */ + public function getSeconds(): int + { + return $this->seconds; + } + + /** + * Returns the "nanoseconds" part of this time duration. + * + * @psalm-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. + * + * @psalm-mutation-free + */ + public function getTotalHours(): float + { + /** @psalm-suppress InvalidOperand */ + 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. + * + * @psalm-mutation-free + */ + public function getTotalMinutes(): float + { + /** @psalm-suppress InvalidOperand */ + 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. + * + * @psalm-mutation-free + */ + public function getTotalSeconds(): float + { + /** @psalm-suppress InvalidOperand */ + 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. + * + * @psalm-mutation-free + */ + public function getTotalMilliseconds(): float + { + /** @psalm-suppress InvalidOperand */ + 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. + * + * @psalm-mutation-free + */ + public function getTotalMicroseconds(): float + { + /** @psalm-suppress InvalidOperand */ + 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. + * + * @psalm-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. + * + * @psalm-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. + * + * @psalm-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)". + * + * @psalm-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)". + * + * @psalm-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)". + * + * @psalm-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)". + * + * @psalm-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 + * + * @psalm-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 + * + * @psalm-mutation-free + */ + public function equals(mixed $other): bool + { + return $this->compare($other) === Comparison\Order::Equal; + } + + /** + * Determines if this duration is shorter than another. + * + * @psalm-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. + * + * @psalm-mutation-free + */ + public function shorterOrEqual(self $other): bool + { + return $this->compare($other) !== Comparison\Order::Greater; + } + + /** + * Determines if this duration is longer than another. + * + * @psalm-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. + * + * @psalm-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. + * + * @psalm-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. + * + * @psalm-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. + * + * @psalm-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)` + * + * @psalm-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()` + * + * @psalm-mutation-free + */ + public function minus(self $other): self + { + if ($other->isZero()) { + return $this; + } + + if ($this->isZero()) { + return $other->invert(); + } + + 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 int<0, max> $max_decimals + * + * @psalm-mutation-free + * + * @psalm-suppress MissingThrowsDocblock + */ + public function toString(int $max_decimals = 3): string + { + $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); + + /** @var list $values */ + $values = [ + [((string) $this->hours), 'hour(s)'], + [((string) $this->minutes), 'minute(s)'], + [$sec_sign . ((string) $sec) . $decimal_part, 'second(s)'], + ]; + + // $end is the sizeof($values), use static value for better performance. + $end = 3; + 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 ([] === $output) ? '0 second(s)' : Str\join($output, ', '); + } + + /** + * Returns a string representation of the time duration. + * + * @psalm-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} + * + * @psalm-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..0637a66a --- /dev/null +++ b/src/Psl/DateTime/Era.php @@ -0,0 +1,44 @@ + 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. + * + * @psalm-mutation-free + */ + 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 @@ +getDaysForYear($year) + )); + } + + /** + * An unexpected hours value. + * + * @param int $value The hours value that was provided. + * + * @return self Instance encapsulating the exception context. + * + * @psalm-mutation-free + * + * @internal + */ + public static function forHours(int $value): self + { + return new self(Str\format('The hour \'%d\' exceeds the expected range of \'0\' to \'23\'.', $value)); + } + + /** + * An unexpected minutes value. + * + * @param int $value The minutes value that was provided. + * + * @return self Instance encapsulating the exception context. + * + * @psalm-mutation-free + * + * @internal + */ + public static function forMinutes(int $value): self + { + return new self(Str\format('The minute \'%d\' steps beyond the bounds of \'0\' to \'59\'.', $value)); + } + + /** + * An unexpected seconds value. + * + * @param int $value The seconds value that was provided. + * + * @return self Instance encapsulating the exception context. + * + * @psalm-mutation-free + * + * @internal + */ + public static function forSeconds(int $value): self + { + return new self(Str\format('The seconds \'%d\' stretch outside the acceptable range of \'0\' to \'59\'.', $value)); + } + + /** + * An unexpected nanoseconds value. + * + * @param int $value The nanoseconds value that was provided. + * + * @return self Instance encapsulating the exception context. + * + * @psalm-mutation-free + * + * @internal + */ + public static function forNanoseconds(int $value): self + { + return new self(Str\format('The nanoseconds \'%d\' exceed the foreseen limit of \'0\' to \'999999999\'.', $value)); + } +} diff --git a/src/Psl/DateTime/Exception/OverflowException.php b/src/Psl/DateTime/Exception/OverflowException.php new file mode 100644 index 00000000..7b5094cd --- /dev/null +++ b/src/Psl/DateTime/Exception/OverflowException.php @@ -0,0 +1,11 @@ +value; + } + + $date_style ??= FormatDateStyle::default(); + $time_style ??= FormatTimeStyle::default(); + $locale ??= Locale::default(); + $timezone ??= Timezone::default(); + + return new IntlDateFormatter( + $locale->value, + match ($date_style) { + FormatDateStyle::None => IntlDateFormatter::NONE, + FormatDateStyle::Short => IntlDateFormatter::SHORT, + FormatDateStyle::Medium => IntlDateFormatter::MEDIUM, + FormatDateStyle::Long => IntlDateFormatter::LONG, + FormatDateStyle::Full => IntlDateFormatter::FULL, + }, + match ($time_style) { + FormatTimeStyle::None => IntlDateFormatter::NONE, + FormatTimeStyle::Short => IntlDateFormatter::SHORT, + FormatTimeStyle::Medium => IntlDateFormatter::MEDIUM, + FormatTimeStyle::Long => IntlDateFormatter::LONG, + FormatTimeStyle::Full => IntlDateFormatter::FULL, + }, + namespace\to_intl_timezone($timezone), + IntlDateFormatter::GREGORIAN, + $pattern, + ); +} diff --git a/src/Psl/DateTime/Internal/default_timezone.php b/src/Psl/DateTime/Internal/default_timezone.php new file mode 100644 index 00000000..dab192f3 --- /dev/null +++ b/src/Psl/DateTime/Internal/default_timezone.php @@ -0,0 +1,29 @@ += NANOSECONDS_PER_SECOND) { + ++$seconds; + $nanoseconds_adjusted -= NANOSECONDS_PER_SECOND; + } elseif ($nanoseconds_adjusted < 0) { + --$seconds; + $nanoseconds_adjusted += NANOSECONDS_PER_SECOND; + } + + $seconds += $seconds_offset; + $nanoseconds = $nanoseconds_adjusted; + + return [$seconds, $nanoseconds]; +} diff --git a/src/Psl/DateTime/Internal/parse.php b/src/Psl/DateTime/Internal/parse.php new file mode 100644 index 00000000..3fdffecd --- /dev/null +++ b/src/Psl/DateTime/Internal/parse.php @@ -0,0 +1,52 @@ +parse($raw_string); + if ($timestamp === false) { + // Only show pattern in the exception if it was provided. + if (null !== $pattern) { + $formatter_pattern = $pattern instanceof FormatPattern ? $pattern->value : $pattern; + + throw new ParserException(Str\format( + 'Unable to interpret \'%s\' as a valid date/time using pattern \'%s\'.', + $raw_string, + $formatter_pattern, + )); + } + + throw new ParserException( + "Unable to interpret '$raw_string' as a valid date/time.", + ); + } + + return (int) $timestamp; +} diff --git a/src/Psl/DateTime/Internal/system_time.php b/src/Psl/DateTime/Internal/system_time.php new file mode 100644 index 00000000..e93ff8ad --- /dev/null +++ b/src/Psl/DateTime/Internal/system_time.php @@ -0,0 +1,32 @@ +value; + if (Byte\starts_with($value, '+') || Byte\starts_with($value, '-')) { + $value = 'GMT' . $value; + } + + $tz = IntlTimeZone::createTimeZone($value); + + Psl\invariant( + $tz !== null, + 'Failed to create intl timezone from timezone "%s" ( "%s" / "%s" ).', + $timezone->name, + $timezone->value, + $value, + ); + + Psl\invariant( + $tz->getID() !== 'Etc/Unknown' || $tz->getRawOffset() !== 0, + 'Failed to create a valid intl timezone, unknown timezone "%s" ( "%s" / "%s" ) given.', + $timezone->name, + $timezone->value, + $value, + ); + + return $tz; +} diff --git a/src/Psl/DateTime/Meridiem.php b/src/Psl/DateTime/Meridiem.php new file mode 100644 index 00000000..587f798e --- /dev/null +++ b/src/Psl/DateTime/Meridiem.php @@ -0,0 +1,44 @@ + $hour The hour in a 24-hour format. + * + * @return Meridiem Returns AnteMeridiem for hours less than 12, and PostMeridiem for hours 12 and above. + * + * @pure + */ + 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. + * + * @psalm-mutation-free + */ + 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..942770f2 --- /dev/null +++ b/src/Psl/DateTime/Month.php @@ -0,0 +1,149 @@ + 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. + * + * @psalm-mutation-free + */ + 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. + * + * @psalm-mutation-free + */ + 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. + * + * @psalm-mutation-free + */ + 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. + * + * @psalm-mutation-free + */ + 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..5742d03b --- /dev/null +++ b/src/Psl/DateTime/TemporalConvenienceMethodsTrait.php @@ -0,0 +1,358 @@ +getTimestamp()->toParts(); + $b = $other->getTimestamp()->toParts(); + + return Comparison\Order::from($a[0] !== $b[0] ? $a[0] <=> $b[0] : $a[1] <=> $b[1]); + } + + /** + * Checks if this {@see TemporalInterface} object represents the same time as the given one. + * + * Note: this method is an alias for {@see TemporalInterface::atTheSameTime()}. + * + * @param TemporalInterface $other + * + * @psalm-mutation-free + * + * @psalm-suppress MoreSpecificImplementedParamType + */ + public function equals(mixed $other): bool + { + return $this->atTheSameTime($other); + } + + /** + * Checks if this temporal object represents the same time as the given one. + * + * Note: this method is an alias for {@see TemporalInterface::equals()}. + * + * @psalm-mutation-free + */ + public function atTheSameTime(TemporalInterface $other): bool + { + return $this->compare($other) === Comparison\Order::Equal; + } + + /** + * Checks if this temporal object is before the given one. + * + * @psalm-mutation-free + */ + public function before(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. + * + * @psalm-mutation-free + */ + public function beforeOrAtTheSameTime(TemporalInterface $other): bool + { + return $this->compare($other) !== Comparison\Order::Greater; + } + + /** + * Checks if this temporal object is after the given one. + * + * @psalm-mutation-free + */ + public function after(TemporalInterface $other): bool + { + return $this->compare($other) === Comparison\Order::Greater; + } + + /** + * Checks if this temporal object is after or at the same time as the given one. + * + * @psalm-mutation-free + */ + public function afterOrAtTheSameTime(TemporalInterface $other): bool + { + return $this->compare($other) !== Comparison\Order::Less; + } + + /** + * Checks if this temporal object is between the given times (inclusive). + * + * @psalm-mutation-free + */ + public function betweenTimeInclusive(TemporalInterface $a, TemporalInterface $b): bool + { + $ca = $this->compare($a); + $cb = $this->compare($b); + + return $ca === Comparison\Order::Equal || $ca !== $cb; + } + + /** + * Checks if this temporal object is between the given times (exclusive). + * + * @psalm-mutation-free + */ + public function betweenTimeExclusive(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. + * + * @psalm-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. + * + * @psalm-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. + * + * @psalm-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. + * + * @psalm-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. + * + * @psalm-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. + * + * @psalm-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. + * + * @psalm-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. + * + * @psalm-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. + * + * @psalm-mutation-free + */ + public function since(TemporalInterface $other): Duration + { + $a = $this->getTimestamp()->toParts(); + $b = $other->getTimestamp()->toParts(); + + 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. + * + * @psalm-mutation-free + */ + public function convertToTimezone(Timezone $timezone): DateTimeInterface + { + return DateTime::fromTimestamp($this->getTimestamp(), $timezone); + } + + /** + * Formats this {@see TemporalInterface} instance based on a specific pattern, with optional customization for timezone and locale. + * + * This method allows for detailed customization of the output string by specifying a format pattern. If no pattern is provided, + * a default, implementation-specific pattern will be used. Additionally, the method supports specifying a timezone and locale + * for further customization of the formatted output. If these are not provided, system defaults will be used. + * + * Example usage: + * + * ```php + * $formatted = $temporal->format('yyyy-MM-dd HH:mm:ss', $timezone, $locale); + * ``` + * + * @param null|FormatPattern|string $pattern Optional custom format pattern for the date and time. If null, uses a default pattern. + * @param null|Timezone $timezone Optional timezone for formatting. If null, uses the system's default timezone. + * @param null|Locale $locale Optional locale for formatting. If null, uses the system's default locale. + * + * @return string The formatted date and time string, according to the specified pattern, timezone, and locale. + * + * @see https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax + * + * @psalm-mutation-free + */ + public function format(null|FormatPattern|string $pattern = null, null|Timezone $timezone = null, null|Locale $locale = null): string + { + $timestamp = $this->getTimestamp(); + + /** + * @psalm-suppress InvalidOperand + * @psalm-suppress ImpureMethodCall + */ + return Internal\create_intl_date_formatter(null, null, $pattern, $timezone, $locale) + ->format($timestamp->getSeconds() + ($timestamp->getNanoseconds() / NANOSECONDS_PER_SECOND)); + } + + /** + * Provides a string representation of this {@see TemporalInterface} instance, formatted according to specified styles for date and time, + * and optionally adjusted for a specific timezone and locale. + * + * This method offers a higher-level abstraction for formatting, allowing users to specify styles for date and time separately + * rather than a custom pattern. If no styles are provided, default styles will be used. + * + * Additionally, the timezone and locale can be specified for locale-sensitive formatting. + * + * Example usage: + * + * ```php + * $string_representation = $temporal->toString(FormatDateStyle::Long, FormatTimeStyle::Short, $timezone, $locale); + * ``` + * + * @param null|FormatDateStyle $date_style Optional style for the date portion of the output. If null, a default style is used. + * @param null|FormatTimeStyle $time_style Optional style for the time portion of the output. If null, a default style is used. + * @param null|Timezone $timezone Optional timezone for formatting. If null, uses the system's default timezone. + * @param null|Locale $locale Optional locale for formatting. If null, uses the system's default locale. + * + * @return string The string representation of the date and time, formatted according to the specified styles, timezone, and locale. + * + * @see FormatDateStyle::default() + * @see FormatTimeStyle::default() + * + * @psalm-mutation-free + */ + public function toString(null|FormatDateStyle $date_style = null, null|FormatTimeStyle $time_style = null, null|Timezone $timezone = null, null|Locale $locale = null): string + { + $timestamp = $this->getTimestamp(); + + /** + * @psalm-suppress InvalidOperand + * @psalm-suppress ImpureMethodCall + */ + return Internal\create_intl_date_formatter($date_style, $time_style, null, $timezone, $locale) + ->format($timestamp->getSeconds() + ($timestamp->getNanoseconds() / NANOSECONDS_PER_SECOND)); + } + + /** + * Magic method that provides a default string representation of the date and time. + * + * This method is a shortcut for calling `toString()` with all null arguments, returning a string formatted + * with default styles, timezone, and locale. It is automatically called when the object is used in a string context. + * + * Example usage: + * + * ```php + * $default_string_representation = (string) $temporal; // Uses __toString() for formatting + * ``` + * + * @return string The default string representation of the date and time. + * + * @see TemporalInterface::toString() + * + * @psalm-mutation-free + */ + public function __toString(): string + { + return $this->toString(); + } +} diff --git a/src/Psl/DateTime/TemporalInterface.php b/src/Psl/DateTime/TemporalInterface.php new file mode 100644 index 00000000..a477e661 --- /dev/null +++ b/src/Psl/DateTime/TemporalInterface.php @@ -0,0 +1,295 @@ + + * @template-extends Equable + */ +interface TemporalInterface extends Comparable, Equable, JsonSerializable, Stringable +{ + /** + * Returns the timestamp representation of this temporal object. + * + * @psalm-mutation-free + */ + public function getTimestamp(): Timestamp; + + /** + * Compare this {@see TemporalInterface} object to the given one. + * + * @param TemporalInterface $other + * + * @psalm-mutation-free + */ + public function compare(mixed $other): Order; + + /** + * Checks if this {@see TemporalInterface} object represents the same time as the given one. + * + * Note: this method is an alias for {@see TemporalInterface::atTheSameTime()}. + * + * @param TemporalInterface $other + * + * @psalm-mutation-free + */ + public function equals(mixed $other): bool; + + /** + * Checks if this temporal object represents the same time as the given one. + * + * @psalm-mutation-free + */ + public function atTheSameTime(TemporalInterface $other): bool; + + /** + * Checks if this temporal object is before the given one. + * + * @psalm-mutation-free + */ + public function before(TemporalInterface $other): bool; + + /** + * Checks if this temporal object is before or at the same time as the given one. + * + * @psalm-mutation-free + */ + public function beforeOrAtTheSameTime(TemporalInterface $other): bool; + + /** + * Checks if this temporal object is after the given one. + * + * @psalm-mutation-free + */ + public function after(TemporalInterface $other): bool; + + /** + * Checks if this temporal object is after or at the same time as the given one. + * + * @psalm-mutation-free + */ + public function afterOrAtTheSameTime(TemporalInterface $other): bool; + + /** + * Checks if this temporal object is between the given times (inclusive). + * + * @psalm-mutation-free + */ + public function betweenTimeInclusive(TemporalInterface $a, TemporalInterface $b): bool; + + /** + * Checks if this temporal object is between the given times (exclusive). + * + * @psalm-mutation-free + */ + public function betweenTimeExclusive(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. + * + * @psalm-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. + * + * @psalm-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. + * + * @psalm-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. + * + * @psalm-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. + * + * @psalm-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. + * + * @psalm-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. + * + * @psalm-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. + * + * @psalm-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. + * + * @psalm-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. + * + * @psalm-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. + * + * @psalm-mutation-free + */ + public function since(TemporalInterface $other): Duration; + + /** + * Formats this {@see TemporalInterface} instance based on a specific pattern, with optional customization for timezone and locale. + * + * This method allows for detailed customization of the output string by specifying a format pattern. If no pattern is provided, + * a default, implementation-specific pattern will be used. Additionally, the method supports specifying a timezone and locale + * for further customization of the formatted output. If these are not provided, system defaults will be used. + * + * Example usage: + * + * ```php + * $formatted = $temporal->format('yyyy-MM-dd HH:mm:ss', $timezone, $locale); + * ``` + * + * @param null|FormatPattern|string $pattern Optional custom format pattern for the date and time. If null, uses a default pattern. + * @param null|Timezone $timezone Optional timezone for formatting. If null, uses the system's default timezone. + * @param null|Locale $locale Optional locale for formatting. If null, uses the system's default locale. + * + * @return string The formatted date and time string, according to the specified pattern, timezone, and locale. + * + * @see https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax + * @see Timezone::default() + * @see Locale::default() + * + * @psalm-mutation-free + */ + public function format(null|FormatPattern|string $pattern = null, null|Timezone $timezone = null, null|Locale $locale = null): string; + + /** + * Provides a string representation of this {@see TemporalInterface} instance, formatted according to specified styles for date and time, + * and optionally adjusted for a specific timezone and locale. + * + * This method offers a higher-level abstraction for formatting, allowing users to specify styles for date and time separately + * rather than a custom pattern. If no styles are provided, default styles will be used. + * + * Additionally, the timezone and locale can be specified for locale-sensitive formatting. + * + * Example usage: + * + * ```php + * $string_representation = $temporal->toString(FormatDateStyle::Long, FormatTimeStyle::Short, $timezone, $locale); + * ``` + * + * @param null|FormatDateStyle $date_style Optional style for the date portion of the output. If null, a default style is used. + * @param null|FormatTimeStyle $time_style Optional style for the time portion of the output. If null, a default style is used. + * @param null|Timezone $timezone Optional timezone for formatting. If null, uses the system's default timezone. + * @param null|Locale $locale Optional locale for formatting. If null, uses the system's default locale. + * + * @return string The string representation of the date and time, formatted according to the specified styles, timezone, and locale. + * + * @see FormatDateStyle::default() + * @see FormatTimeStyle::default() + * @see Timezone::default() + * @see Locale::default() + * + * @psalm-mutation-free + */ + public function toString(null|FormatDateStyle $date_style = null, null|FormatTimeStyle $time_style = null, null|Timezone $timezone = null, null|Locale $locale = null): string; + + /** + * Magic method that provides a default string representation of the date and time. + * + * This method is a shortcut for calling `toString()` with all null arguments, returning a string formatted + * with default styles, timezone, and locale. It is automatically called when the object is used in a string context. + * + * Example usage: + * + * ```php + * $default_string_representation = (string) $temporal; // Uses __toString() for formatting + * ``` + * + * @return string The default string representation of the date and time. + * + * @see TemporalInterface::toString() + * + * @psalm-mutation-free + */ + public function __toString(): string; + + /** + * Converts the current temporal object to a new {@see DateTimeInterface} instance in a different timezone. + * + * @param Timezone $timezone The target timezone for the conversion. + * + * @psalm-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..eef3357c --- /dev/null +++ b/src/Psl/DateTime/Timestamp.php @@ -0,0 +1,273 @@ + $nanoseconds + * + * @pure + */ + private function __construct( + private int $seconds, + private int $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 fromParts(int $seconds, int $nanoseconds = 0): Timestamp + { + // Check for potential overflow or underflow before doing any operation + if ($seconds === Math\INT64_MAX && $nanoseconds >= NANOSECONDS_PER_SECOND) { + throw new Exception\OverflowException("Adding nanoseconds would cause an overflow."); + } + + if ($seconds === Math\INT64_MIN && $nanoseconds <= -NANOSECONDS_PER_SECOND) { + throw new Exception\UnderflowException("Subtracting nanoseconds would cause an underflow."); + } + + /** @psalm-suppress MissingThrowsDocblock */ + $seconds_adjustment = Math\div($nanoseconds, NANOSECONDS_PER_SECOND); + $adjusted_seconds = $seconds + $seconds_adjustment; + + $adjusted_nanoseconds = $nanoseconds % NANOSECONDS_PER_SECOND; + if ($adjusted_nanoseconds < 0) { + --$adjusted_seconds; + $adjusted_nanoseconds += NANOSECONDS_PER_SECOND; + } + + return new self($adjusted_seconds, $adjusted_nanoseconds); + } + + /** + * Create a high-precision instance representing the current time using the system clock. + * + * @psalm-mutation-free + */ + public static function now(): self + { + [$seconds, $nanoseconds] = Internal\system_time(); + + /** @psalm-suppress MissingThrowsDocblock */ + return self::fromParts($seconds, $nanoseconds); + } + + /** + * Create a current time instance using a monotonic clock with high precision + * to the nanosecond for precise measurements. + * + * This method ensures that the time is always moving forward, unaffected by adjustments in the system clock, + * making it suitable for measuring durations or intervals accurately. + * + * @throws InvariantViolationException If the system does not provide a monotonic timer. + * + * @psalm-mutation-free + */ + public static function monotonic(): self + { + [$seconds, $nanoseconds] = Internal\high_resolution_time(); + + /** @psalm-suppress MissingThrowsDocblock */ + return self::fromParts($seconds, $nanoseconds); + } + + /** + * Parses a date and time string into an instance of {@see Timestamp} using a specific format pattern, with optional customization for timezone and locale. + * + * This method is specifically designed for cases where a custom format pattern is used to parse the input string. + * + * It allows for precise control over the parsing process by specifying the exact format pattern that matches the input string. + * + * Additionally, the method supports specifying a timezone and locale for parsing, enabling accurate interpretation of locale-specific formats. + * + * Example usage: + * + * ```php + * $raw_string = '2023-03-15 12:00:00'; + * $parsed_timestamp = DateTime\Timestamp::parse($raw_string, 'yyyy-MM-dd HH:mm:ss', DateTime\Timezone::Utc, Locale\Locale::English); + * ``` + * + * @param string $raw_string The date and time string to parse. + * @param null|FormatPattern|string $pattern The custom format pattern for parsing the date and time. If null, uses a default pattern. + * @param null|Timezone $timezone Optional timezone for parsing. If null, uses the system's default timezone. + * @param null|Locale $locale Optional locale for parsing. If null, uses the system's default locale. + * + * @throws Exception\RuntimeException If the parsing process fails. + * + * @return static Returns an instance of {@see Timestamp} representing the parsed date and time. + * + * @see https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax + * @see TemporalInterface::format() + * + * @psalm-mutation-free + */ + public static function parse(string $raw_string, null|FormatPattern|string $pattern = null, null|Timezone $timezone = null, null|Locale $locale = null): static + { + /** @psalm-suppress MissingThrowsDocblock */ + return self::fromParts( + Internal\parse(raw_string: $raw_string, pattern: $pattern, timezone: $timezone, locale: $locale) + ); + } + + /** + * Creates an instance of {@see Timestamp} from a date and time string, formatted according to specified styles for date and time, + * with optional customization for timezone and locale. + * + * This method provides a more abstracted approach to parsing, allowing users to specify styles rather than a custom pattern. + * + * This is particularly useful for parsing strings that follow common date and time formats. + * + * Additionally, the timezone and locale parameters enable accurate parsing of strings in locale-specific formats. + * + * Example usage: + * + * ```php + * $raw_string = "March 15, 2023, 12:00 PM"; + * + * $timestamp = DateTime\Timestamp::fromString($raw_string, FormatDateStyle::Long, FormatTimeStyle::Short, DateTime\Timezone::Utc, Locale\Locale::English); + * ``` + * + * @param string $raw_string The date and time string to parse. + * @param null|FormatDateStyle $date_style The style for the date portion of the string. If null, a default style is used. + * @param null|FormatTimeStyle $time_style The style for the time portion of the string. If null, a default style is used. + * @param null|Timezone $timezone Optional timezone for parsing. If null, uses the system's default timezone. + * @param null|Locale $locale Optional locale for parsing. If null, uses the system's default locale. + * + * @throws Exception\RuntimeException If the parsing process fails. + * + * @return static Returns an instance of {@see Timestamp} representing the parsed date and time. + * + * @see TemporalInterface::toString() + * + * @psalm-mutation-free + */ + public static function fromString(string $raw_string, null|FormatDateStyle $date_style = null, null|FormatTimeStyle $time_style = null, null|Timezone $timezone = null, null|Locale $locale = null): static + { + /** @psalm-suppress MissingThrowsDocblock */ + return self::fromParts( + Internal\parse(raw_string: $raw_string, date_style: $date_style, time_style: $time_style, timezone: $timezone, locale: $locale) + ); + } + + /** + * Returns this Timestamp instance itself, as it already represents a timestamp. + * + * @psalm-mutation-free + */ + public function getTimestamp(): self + { + return $this; + } + + /** + * Returns the {@see Timestamp} parts (seconds, nanoseconds). + * + * @return array{int, int<0, 999999999>} + * + * @psalm-mutation-free + */ + public function toParts(): 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. + * + * @psalm-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. + * + * @psalm-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. + * + * @psalm-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::fromParts($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. + * + * @psalm-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::fromParts($newSeconds, $newNanoseconds); + } + + 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..949b1c05 --- /dev/null +++ b/src/Psl/DateTime/Timezone.php @@ -0,0 +1,596 @@ +getTimestamp()->getSeconds() * MILLISECONDS_PER_SECOND; + /** @psalm-suppress ImpureMethodCall - mutation free */ + $intl_timezone->getOffset($timestamp_millis, $local, $raw_offset, $dst_offset); + + return Duration::milliseconds($raw_offset + $dst_offset); + } + + /** + * Calculates the raw time zone offset for the current timezone, excluding any daylight saving time (DST) adjustments. + * + * This method retrieves the fixed offset from UTC for the timezone without considering any seasonal adjustments + * that might apply due to DST. It's particularly useful for understanding the base offset of a timezone. + * + * @psalm-mutation-free + */ + public function getRawOffset(): Duration + { + /** @psalm-suppress ImpureMethodCall - mutation free */ + return Duration::milliseconds(Internal\to_intl_timezone($this)->getRawOffset()); + } + + /** + * Calculates the daylight saving time (DST) offset for a given {@see TemporalInterface} instance at its specific time. + * + * This DST offset is the adjustment added to the raw timezone offset, if DST is in effect at the temporal instance's time. + * + * @param bool $local Indicates whether the temporal object's time should be treated as local time (`true`) or as UTC time (`false`). + * + * @return Duration The DST offset as a Duration instance. If DST is not in effect, the offset will be zero. + * + * @psalm-mutation-free + */ + public function getDaylightSavingTimeOffset(TemporalInterface $temporal, bool $local = false): Duration + { + $intl_timezone = Internal\to_intl_timezone($this); + $timestamp_millis = $temporal->getTimestamp()->getSeconds() * MILLISECONDS_PER_SECOND; + /** @psalm-suppress ImpureMethodCall - mutation free */ + $intl_timezone->getOffset($timestamp_millis, $local, $_, $dst_offset); + + return Duration::milliseconds($dst_offset); + } + + /** + * Determines whether the current timezone observes Daylight Saving Time (DST). + * + * This method checks if the timezone has any DST rules and if DST is applied at any point during the year. + * + * @return bool True if the timezone uses Daylight Saving Time at any point in the year, false otherwise. + * + * @psalm-mutation-free + */ + public function usesDaylightSavingTime(): bool + { + /** @psalm-suppress ImpureMethodCall - mutation free */ + return Internal\to_intl_timezone($this)->useDaylightTime(); + } + + /** + * Retrieves the amount of time added during Daylight Saving Time for the current timezone. + * + * This method returns the typical adjustment made to the local time when DST is in effect. + * + * If the timezone does not observe DST or if there is no current DST adjustment (e.g., outside of DST periods), + * the method will return a Duration of zero. + * + * @psalm-mutation-free + */ + public function getDaylightSavingTimeSavings(): Duration + { + /** @psalm-suppress ImpureMethodCall - mutation free */ + return Duration::milliseconds(Internal\to_intl_timezone($this)->getDSTSavings()); + } + + /** + * Determines whether the current timezone has the same rules as another specified timezone. + * + * @psalm-mutation-free + */ + public function hasTheSameRulesAs(Timezone $other): bool + { + /** @psalm-suppress ImpureMethodCall - mutation free */ + return Internal\to_intl_timezone($this)->hasSameRules(Internal\to_intl_timezone($other)); + } +} diff --git a/src/Psl/DateTime/Weekday.php b/src/Psl/DateTime/Weekday.php new file mode 100644 index 00000000..d0c6d5b4 --- /dev/null +++ b/src/Psl/DateTime/Weekday.php @@ -0,0 +1,68 @@ + 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. + * + * @psalm-mutation-free + */ + 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..c1e9936b --- /dev/null +++ b/src/Psl/DateTime/constants.php @@ -0,0 +1,124 @@ +readHandle->read($max_bytes, $timeout); } diff --git a/src/Psl/File/ReadWriteHandle.php b/src/Psl/File/ReadWriteHandle.php index 0795c412..1f6cbe1c 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; @@ -86,7 +87,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, ?Duration $timeout = null): string { return $this->readWriteHandle->read($max_bytes, $timeout); } @@ -102,7 +103,7 @@ public function tryWrite(string $bytes): int /** * {@inheritDoc} */ - public function write(string $bytes, ?float $timeout = null): int + public function write(string $bytes, ?Duration $timeout = null): int { return $this->readWriteHandle->write($bytes, $timeout); } diff --git a/src/Psl/File/WriteHandle.php b/src/Psl/File/WriteHandle.php index d1671fdb..c0651575 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, ?Duration $timeout = null): int { return $this->writeHandle->write($bytes, $timeout); } diff --git a/src/Psl/IO/CloseReadStreamHandle.php b/src/Psl/IO/CloseReadStreamHandle.php index 3ec42f13..f61dba4c 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; /** @@ -42,7 +43,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, ?Duration $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 70d3fc6c..d3a56f14 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; /** @@ -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, ?Duration $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, ?Duration $timeout = null): int { return $this->handle->write($bytes, $timeout); } diff --git a/src/Psl/IO/CloseSeekReadStreamHandle.php b/src/Psl/IO/CloseSeekReadStreamHandle.php index fe1269b8..b88fbd56 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; /** @@ -42,7 +43,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, ?Duration $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 38928c26..02fc796e 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; /** @@ -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, ?Duration $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, ?Duration $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..480ae3e0 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, ?Duration $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..76017268 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, ?Duration $timeout = null): int { return $this->handle->write($bytes, $timeout); } diff --git a/src/Psl/IO/Internal/ResourceHandle.php b/src/Psl/IO/Internal/ResourceHandle.php index a2664bf9..a42789e3 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; @@ -51,14 +52,14 @@ class ResourceHandle implements IO\CloseSeekReadWriteStreamHandleInterface protected mixed $stream; /** - * @var null|Async\Sequence> + * @var null|Async\Sequence> */ private ?Async\Sequence $writeSequence = null; private ?Suspension $writeSuspension = null; private string $writeWatcher = 'invalid'; /** - * @var null|Async\Sequence, null|float}, string> + * @var null|Async\Sequence, null|Duration}, string> */ private ?Async\Sequence $readSequence = null; private ?Suspension $readSuspension = null; @@ -103,7 +104,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} $input */ function (array $input) use ($blocks): string { [$max_bytes, $timeout] = $input; @@ -116,7 +117,7 @@ function (array $input) use ($blocks): string { EventLoop::enable($this->readWatcher); $delay_watcher = null; if (null !== $timeout) { - $timeout = max($timeout, 0.0); + $timeout = max($timeout->getTotalSeconds(), 0.0); $delay_watcher = EventLoop::delay( $timeout, static fn () => $suspension->throw(new Exception\TimeoutException('Reached timeout while the handle is still not readable.')), @@ -159,7 +160,7 @@ function (array $input) use ($blocks): string { $this->writeSequence = new Async\Sequence( /** - * @param array{string, null|float} $input + * @param array{string, null|Duration} $input * * @return int<0, max> */ @@ -175,7 +176,7 @@ function (array $input) use ($blocks): int { EventLoop::enable($this->writeWatcher); $delay_watcher = null; if (null !== $timeout) { - $timeout = max($timeout, 0.0); + $timeout = max($timeout->getTotalSeconds(), 0.0); $delay_watcher = EventLoop::delay( $timeout, static fn () => $suspension->throw(new Exception\TimeoutException('Reached timeout while the handle is still not readable.')), @@ -204,7 +205,7 @@ function (array $input) use ($blocks): int { /** * {@inheritDoc} */ - public function write(string $bytes, ?float $timeout = null): int + public function write(string $bytes, ?Duration $timeout = null): int { Psl\invariant($this->writeSequence !== null, 'The resource handle is not writable.'); @@ -283,7 +284,7 @@ public function reachedEndOfDataSource(): bool /** * {@inheritDoc} */ - public function read(?int $max_bytes = null, ?float $timeout = null): string + public function read(?int $max_bytes = null, ?Duration $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 a1fda6e9..f8182e43 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; @@ -76,7 +77,7 @@ public function tryRead(?int $max_bytes = null): string * * @psalm-external-mutation-free */ - public function read(?int $max_bytes = null, ?float $timeout = null): string + public function read(?int $max_bytes = null, ?Duration $timeout = null): string { return $this->tryRead($max_bytes); } @@ -110,7 +111,7 @@ public function tell(): int * * @psalm-external-mutation-free */ - public function tryWrite(string $bytes, ?float $timeout = null): int + public function tryWrite(string $bytes, ?Duration $timeout = null): int { $this->assertHandleIsOpen(); $length = strlen($this->buffer); @@ -137,7 +138,7 @@ public function tryWrite(string $bytes, ?float $timeout = null): int * * @psalm-external-mutation-free */ - public function write(string $bytes, ?float $timeout = null): int + public function write(string $bytes, ?Duration $timeout = null): int { return $this->tryWrite($bytes); } diff --git a/src/Psl/IO/ReadHandleConvenienceMethodsTrait.php b/src/Psl/IO/ReadHandleConvenienceMethodsTrait.php index e48fa411..8cede651 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, ?Duration $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, ?Duration $timeout = null): string { $data = $this->readAll($size, $timeout); diff --git a/src/Psl/IO/ReadHandleInterface.php b/src/Psl/IO/ReadHandleInterface.php index 01869e09..9de72326 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. */ @@ -61,7 +63,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, ?Duration $timeout = null): string; /** * Read until there is no more data to read. @@ -79,7 +81,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, ?Duration $timeout = null): string; /** * Read a fixed amount of data. @@ -94,5 +96,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, ?Duration $timeout = null): string; } diff --git a/src/Psl/IO/ReadStreamHandle.php b/src/Psl/IO/ReadStreamHandle.php index 6c71c877..9530daba 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; /** @@ -42,7 +43,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, ?Duration $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 fb4a67ba..9f30d5c4 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; /** @@ -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, ?Duration $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, ?Duration $timeout = null): int { return $this->handle->write($bytes, $timeout); } diff --git a/src/Psl/IO/Reader.php b/src/Psl/IO/Reader.php index 906e609f..ee5b3e0b 100644 --- a/src/Psl/IO/Reader.php +++ b/src/Psl/IO/Reader.php @@ -5,6 +5,7 @@ namespace Psl\IO; use Psl\Async; +use Psl\DateTime\Duration; use Psl\Str; use function strlen; @@ -57,7 +58,7 @@ public function reachedEndOfDataSource(): bool /** * {@inheritDoc} */ - public function readFixedSize(int $size, ?float $timeout = null): string + public function readFixedSize(int $size, ?Duration $timeout = null): string { $timer = new Async\OptionalIncrementalTimeout( $timeout, @@ -100,7 +101,7 @@ function (): 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(?Duration $timeout = null): string { if ($this->buffer === '' && !$this->eof) { $this->fillBuffer(null, $timeout); @@ -128,7 +129,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(?Duration $timeout = null): ?string { $timer = new Async\OptionalIncrementalTimeout( $timeout, @@ -164,7 +165,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, ?Duration $timeout = null): ?string { $buf = $this->buffer; $idx = strpos($buf, $suffix); @@ -208,7 +209,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, ?Duration $timeout = null): string { if ($this->eof) { return ''; @@ -262,7 +263,7 @@ public function getHandle(): ReadHandleInterface * @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, ?Duration $timeout): void { $this->buffer .= $chunk = $this->handle->read($desired_bytes, $timeout); if ($chunk === '') { diff --git a/src/Psl/IO/SeekReadStreamHandle.php b/src/Psl/IO/SeekReadStreamHandle.php index ca74f2e3..bc0a0392 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; /** @@ -42,7 +43,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, ?Duration $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 ea78e8ce..80b35fcc 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; /** @@ -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, ?Duration $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, ?Duration $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..aaa6bb7e 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, ?Duration $timeout = null): int { return $this->handle->write($bytes, $timeout); } diff --git a/src/Psl/IO/WriteHandleConvenienceMethodsTrait.php b/src/Psl/IO/WriteHandleConvenienceMethodsTrait.php index 2b5ddd1f..fd851c66 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, ?Duration $timeout = null): void { if ($bytes === '') { return; diff --git a/src/Psl/IO/WriteHandleInterface.php b/src/Psl/IO/WriteHandleInterface.php index 6a47aed5..406c1f92 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, ?Duration $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, ?Duration $timeout = null): void; } diff --git a/src/Psl/IO/WriteStreamHandle.php b/src/Psl/IO/WriteStreamHandle.php index 2bb4624d..bf746997 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, ?Duration $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..463d3c2d 100644 --- a/src/Psl/IO/streaming.php +++ b/src/Psl/IO/streaming.php @@ -7,10 +7,13 @@ use Generator; use Psl; use Psl\Channel; +use Psl\DateTime\Duration; use Psl\Result; use Psl\Str; use Revolt\EventLoop; +use function max; + /** * Streaming the output of the given read stream handles using a generator. * @@ -35,7 +38,7 @@ * * @return Generator */ -function streaming(iterable $handles, ?float $timeout = null): Generator +function streaming(iterable $handles, ?Duration $timeout = null): Generator { /** * @psalm-suppress UnnecessaryVarAnnotation @@ -72,6 +75,8 @@ function streaming(iterable $handles, ?float $timeout = null): Generator $timeout_watcher = null; if ($timeout !== null) { + $timeout = max($timeout->getTotalSeconds(), 0.0); + $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 4d9ea29e..641dca77 100644 --- a/src/Psl/Internal/Loader.php +++ b/src/Psl/Internal/Loader.php @@ -54,6 +54,23 @@ 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', + 'Psl\\DateTime\\MONTHS_PER_YEAR' => 'Psl/DateTime/constants.php', ]; public const FUNCTIONS = [ @@ -521,6 +538,13 @@ 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\\to_intl_timezone' => 'Psl/DateTime/Internal/to_intl_timezone.php', + 'Psl\\DateTime\\Internal\\default_timezone' => 'Psl/DateTime/Internal/default_timezone.php', + 'Psl\\DateTime\\Internal\\system_time' => 'Psl/DateTime/Internal/system_time.php', + 'Psl\\DateTime\\Internal\\high_resolution_time' => 'Psl/DateTime/Internal/high_resolution_time.php', + 'Psl\\DateTime\\Internal\\create_intl_date_formatter' => 'Psl/DateTime/Internal/create_intl_date_formatter.php', + 'Psl\\DateTime\\Internal\\parse' => 'Psl/DateTime/Internal/parse.php', ]; public const INTERFACES = [ @@ -613,6 +637,9 @@ final class Loader 'Psl\\Range\\LowerBoundRangeInterface' => 'Psl/Range/LowerBoundRangeInterface.php', 'Psl\\Range\\UpperBoundRangeInterface' => 'Psl/Range/UpperBoundRangeInterface.php', 'Psl\\Default\\DefaultInterface' => 'Psl/Default/DefaultInterface.php', + 'Psl\\DateTime\\Exception\\ExceptionInterface' => 'Psl/DateTime/Exception/ExceptionInterface.php', + 'Psl\\DateTime\\TemporalInterface' => 'Psl/DateTime/TemporalInterface.php', + 'Psl\\DateTime\\DateTimeInterface' => 'Psl/DateTime/DateTimeInterface.php', ]; public const TRAITS = [ @@ -620,6 +647,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\\TemporalConvenienceMethodsTrait' => 'Psl/DateTime/TemporalConvenienceMethodsTrait.php', + 'Psl\\DateTime\\DateTimeConvenienceMethodsTrait' => 'Psl/DateTime/DateTimeConvenienceMethodsTrait.php', ]; public const CLASSES = [ @@ -639,6 +668,7 @@ final class Loader 'Psl\\Encoding\\Base64\\Internal\\Base64DotSlashOrdered' => 'Psl/Encoding/Base64/Internal/Base64DotSlashOrdered.php', 'Psl\\Exception\\OverflowException' => 'Psl/Exception/OverflowException.php', 'Psl\\Exception\\InvalidArgumentException' => 'Psl/Exception/InvalidArgumentException.php', + 'Psl\\Exception\\UnexpectedValueException' => 'Psl/Exception/UnexpectedValueException.php', 'Psl\\Exception\\RuntimeException' => 'Psl/Exception/RuntimeException.php', 'Psl\\Exception\\InvariantViolationException' => 'Psl/Exception/InvariantViolationException.php', 'Psl\\Exception\\UnderflowException' => 'Psl/Exception/UnderflowException.php', @@ -815,6 +845,14 @@ 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\\RuntimeException' => 'Psl/DateTime/Exception/RuntimeException.php', + 'Psl\\DateTime\\Exception\\ParserException' => 'Psl/DateTime/Exception/ParserException.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', ]; public const ENUMS = [ @@ -831,6 +869,14 @@ 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\\FormatPattern' => 'Psl/DateTime/FormatPattern.php', + 'Psl\\DateTime\\FormatTimeStyle' => 'Psl/DateTime/FormatTimeStyle.php', + 'Psl\\DateTime\\FormatDateStyle' => 'Psl/DateTime/FormatDateStyle.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/Locale/Locale.php b/src/Psl/Locale/Locale.php index e93d9ed2..bd72fa38 100644 --- a/src/Psl/Locale/Locale.php +++ b/src/Psl/Locale/Locale.php @@ -810,6 +810,8 @@ enum Locale: string * @return self The default locale as an enum instance, sourced from PHP settings or `self::English` as the fallback. * * @see https://www.php.net/manual/en/locale.getdefault.php + * + * @psalm-mutation-free */ public static function default(): self { diff --git a/src/Psl/Network/Internal/Socket.php b/src/Psl/Network/Internal/Socket.php index 25f61908..43f5327f 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; @@ -51,7 +52,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, ?Duration $timeout = null): string { return $this->handle->read($max_bytes, $timeout); } @@ -67,7 +68,7 @@ public function tryWrite(string $bytes): int /** * {@inheritDoc} */ - public function write(string $bytes, ?float $timeout = null): int + public function write(string $bytes, ?Duration $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..088e92bd 100644 --- a/src/Psl/Network/Internal/socket_connect.php +++ b/src/Psl/Network/Internal/socket_connect.php @@ -4,12 +4,14 @@ namespace Psl\Network\Internal; +use Psl\DateTime\Duration; use Psl\Internal; use Psl\Network\Exception; use Revolt\EventLoop; use function fclose; use function is_resource; +use function max; use function stream_context_create; use function stream_socket_client; @@ -28,7 +30,7 @@ * * @codeCoverageIgnore */ -function socket_connect(string $uri, array $context = [], ?float $timeout = null): mixed +function socket_connect(string $uri, array $context = [], ?Duration $timeout = null): mixed { return Internal\suppress(static function () use ($uri, $context, $timeout): mixed { $context = stream_context_create($context); @@ -42,6 +44,7 @@ function socket_connect(string $uri, array $context = [], ?float $timeout = null $write_watcher = ''; $timeout_watcher = ''; if (null !== $timeout) { + $timeout = max($timeout->getTotalSeconds(), 0.0); $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..090651c3 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 + ?Duration $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..ed26a37d 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, ?Duration $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 bdcbf34b..9ceb71ed 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, ?Duration $timeout = null): Network\StreamSocketInterface { // @codeCoverageIgnoreStart if (OS\is_windows()) { diff --git a/tests/unit/Async/AllTest.php b/tests/unit/Async/AllTest.php index e06a486c..26957cd3 100644 --- a/tests/unit/Async/AllTest.php +++ b/tests/unit/Async/AllTest.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\TestCase; use Psl; use Psl\Async; +use Psl\DateTime; use Psl\Exception\InvariantViolationException; final class AllTest extends TestCase @@ -15,17 +16,17 @@ public function testAll(): void { $awaitables = [ 'a' => Async\run(static function (): string { - Async\sleep(0.003); + Async\sleep(DateTime\Duration::milliseconds(3)); return 'a'; }), 'b' => Async\run(static function (): string { - Async\sleep(0.001); + Async\sleep(DateTime\Duration::milliseconds(1)); return 'b'; }), 'c' => Async\run(static function (): string { - Async\sleep(0.01); + Async\sleep(DateTime\Duration::milliseconds(10)); return 'c'; }), @@ -81,23 +82,23 @@ public function testAllAwaitablesAreCompletedAtALaterTime(): void throw new InvariantViolationException('a'); }), Async\run(static function () use ($ref): void { - Async\sleep(0.02); + Async\sleep(DateTime\Duration::milliseconds(20)); $ref->value .= 'b'; throw new InvariantViolationException('b'); }), Async\run(static function () use ($ref): void { - Async\sleep(0.05); + Async\sleep(DateTime\Duration::milliseconds(50)); $ref->value .= 'c'; }), Async\run(static function () use ($ref): void { - Async\sleep(0.00005); + Async\sleep(DateTime\Duration::microseconds(5)); Async\later(); - Async\sleep(0.00005); + Async\sleep(DateTime\Duration::microseconds(5)); $ref->value .= 'd'; }), diff --git a/tests/unit/Async/AnyTest.php b/tests/unit/Async/AnyTest.php index 1359d137..a054a6d3 100644 --- a/tests/unit/Async/AnyTest.php +++ b/tests/unit/Async/AnyTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\TestCase; use Psl\Async; +use Psl\DateTime; use Psl\Exception\InvariantViolationException; final class AnyTest extends TestCase @@ -14,26 +15,26 @@ public function testAny(): void { $result = Async\any([ Async\run(static function (): string { - Async\sleep(0.0001); + Async\sleep(DateTime\Duration::milliseconds(1)); throw new InvariantViolationException('a'); }), Async\run(static function (): string { - Async\sleep(0.0002); + Async\sleep(DateTime\Duration::milliseconds(2)); throw new InvariantViolationException('b'); }), Async\run(static function (): string { - Async\sleep(0.0003); + Async\sleep(DateTime\Duration::milliseconds(3)); return 'c'; }), Async\run(static function (): string { - Async\sleep(0.00005); + Async\sleep(DateTime\Duration::microseconds(500)); Async\later(); - Async\sleep(0.00005); + Async\sleep(DateTime\Duration::microseconds(500)); return 'c'; }), diff --git a/tests/unit/Async/AwaitableTest.php b/tests/unit/Async/AwaitableTest.php index 1784c735..b694e75a 100644 --- a/tests/unit/Async/AwaitableTest.php +++ b/tests/unit/Async/AwaitableTest.php @@ -10,6 +10,7 @@ use Psl\Async\Awaitable; use Psl\Async\Exception\UnhandledAwaitableException; use Psl\Async\Internal\State; +use Psl\DateTime; use Psl\Dict; use Psl\Exception\InvariantViolationException; use Psl\Str; @@ -94,12 +95,12 @@ public function testIterate(): void 'foo' => Awaitable::complete('foo'), 'bar' => Awaitable::error(new InvariantViolationException('bar')), 'baz' => Async\run(static function () { - Async\sleep(0.0001); + Async\sleep(DateTime\Duration::milliseconds(1)); throw new InvariantViolationException('baz'); }), 'qux' => Async\run(static function () { - Async\sleep(0.003); + Async\sleep(DateTime\Duration::milliseconds(30)); return 'qux'; }), @@ -141,7 +142,7 @@ public function testIterateGenerator(): void $generator1 = Async\run(static function (): iterable { yield 'foo' => 'foo'; - Async\sleep(0.0003); + Async\sleep(DateTime\Duration::milliseconds(3)); yield 'bar' => 'bar'; }); @@ -149,7 +150,7 @@ public function testIterateGenerator(): void $generator2 = Async\run(static function (): iterable { yield 'baz' => 'baz'; - Async\sleep(0.0001); + Async\sleep(DateTime\Duration::milliseconds(1)); yield 'qux' => 'qux'; }); @@ -157,7 +158,7 @@ public function testIterateGenerator(): void $generator3 = Async\run(static function () use ($generator1, $generator2): iterable { yield 'gen1' => $generator1; - Async\sleep(0.0002); + Async\sleep(DateTime\Duration::milliseconds(2)); yield 'gen2' => $generator2; })->await(); diff --git a/tests/unit/Async/DeferredTest.php b/tests/unit/Async/DeferredTest.php index e1995e51..f9a80b5d 100644 --- a/tests/unit/Async/DeferredTest.php +++ b/tests/unit/Async/DeferredTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\TestCase; use Psl\Async; +use Psl\DateTime; use Psl\Exception\InvariantViolationException; final class DeferredTest extends TestCase @@ -15,7 +16,7 @@ public function testComplete(): void $deferred = new Async\Deferred(); $placeholder = Async\run(static function () use ($deferred) { - Async\sleep(0.0001); + Async\sleep(DateTime\Duration::milliseconds(1)); $deferred->complete('hello'); }); @@ -34,7 +35,7 @@ public function testError(): void $deferred = new Async\Deferred(); $placeholder = Async\run(static function () use ($deferred) { - Async\sleep(0.0001); + Async\sleep(DateTime\Duration::milliseconds(1)); $deferred->error(new InvariantViolationException('hello')); }); diff --git a/tests/unit/Async/FirstTest.php b/tests/unit/Async/FirstTest.php index 4702b5c2..48f6be5f 100644 --- a/tests/unit/Async/FirstTest.php +++ b/tests/unit/Async/FirstTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\TestCase; use Psl\Async; +use Psl\DateTime; final class FirstTest extends TestCase { @@ -13,26 +14,26 @@ public function testFirst(): void { $result = Async\first([ Async\run(static function (): string { - Async\sleep(0.001); + Async\sleep(DateTime\Duration::milliseconds(1)); return 'a'; }), Async\run(static function (): string { - Async\sleep(0.002); + Async\sleep(DateTime\Duration::milliseconds(2)); return 'b'; }), Async\run(static function (): string { - Async\sleep(0.003); + Async\sleep(DateTime\Duration::milliseconds(3)); return 'c'; }), Async\run(static function (): string { - Async\sleep(0.0005); + Async\sleep(DateTime\Duration::milliseconds(5)); Async\later(); - Async\sleep(0.0005); + Async\sleep(DateTime\Duration::milliseconds(5)); return 'c'; }), diff --git a/tests/unit/Async/KeyedSemaphoreTest.php b/tests/unit/Async/KeyedSemaphoreTest.php index b731c419..cc9821c5 100644 --- a/tests/unit/Async/KeyedSemaphoreTest.php +++ b/tests/unit/Async/KeyedSemaphoreTest.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\TestCase; use Psl; use Psl\Async; +use Psl\DateTime; final class KeyedSemaphoreTest extends TestCase { @@ -26,7 +27,7 @@ public function testSequenceOperationWaitsForPendingOperationsWhenLimitIsNotReac $spy = new Psl\Ref([]); /** - * @var Async\KeyedSemaphore + * @var Async\KeyedSemaphore */ $ks = new Async\KeyedSemaphore(1, static function (string $key, array $data) use ($spy): void { static::assertSame('operation', $key); @@ -38,9 +39,9 @@ public function testSequenceOperationWaitsForPendingOperationsWhenLimitIsNotReac $spy->value[] = $data['value']; }); - Async\run(static fn() => $ks->waitFor('operation', ['time' => 0.003, 'value' => 'a'])); - Async\run(static fn() => $ks->waitFor('operation', ['time' => 0.004, 'value' => 'b'])); - Async\run(static fn() => $ks->waitFor('operation', ['time' => 0.005, 'value' => 'c'])); + Async\run(static fn() => $ks->waitFor('operation', ['time' => DateTime\Duration::milliseconds(3), 'value' => 'a'])); + Async\run(static fn() => $ks->waitFor('operation', ['time' => DateTime\Duration::milliseconds(4), 'value' => 'b'])); + Async\run(static fn() => $ks->waitFor('operation', ['time' => DateTime\Duration::milliseconds(5), 'value' => 'c'])); $last = Async\run(static fn() => $ks->waitFor('operation', ['time' => null, 'value' => 'd'])); $last->await(); @@ -52,7 +53,7 @@ public function testOperationWaitsForPendingOperationsWhenLimitIsNotReached(): v $spy = new Psl\Ref([]); /** - * @var Async\KeyedSemaphore + * @var Async\KeyedSemaphore */ $ks = new Async\KeyedSemaphore(2, static function (string $_, array $data) use ($spy): void { if ($data['time'] !== null) { @@ -62,9 +63,9 @@ public function testOperationWaitsForPendingOperationsWhenLimitIsNotReached(): v $spy->value[] = $data['value']; }); - Async\run(static fn() => $ks->waitFor('key', ['time' => 0.003, 'value' => 'a'])); - Async\run(static fn() => $ks->waitFor('key', ['time' => 0.004, 'value' => 'b'])); - $beforeLast = Async\run(static fn() => $ks->waitFor('key', ['time' => 0.005, 'value' => 'c'])); + Async\run(static fn() => $ks->waitFor('key', ['time' => DateTime\Duration::milliseconds(3), 'value' => 'a'])); + Async\run(static fn() => $ks->waitFor('key', ['time' => DateTime\Duration::milliseconds(4), 'value' => 'b'])); + $beforeLast = Async\run(static fn() => $ks->waitFor('key', ['time' => DateTime\Duration::milliseconds(5), 'value' => 'c'])); Async\run(static fn() => $ks->waitFor('key', ['time' => null, 'value' => 'd'])); $beforeLast->await(); @@ -82,7 +83,7 @@ public function testOperationIsStartedIfLimitIsNotReached(): void $ks = new Async\KeyedSemaphore(1, static function (string $_, string $input) use ($spy): void { $spy->value[] = $input; - Async\sleep(0.002); + Async\sleep(DateTime\Duration::milliseconds(2)); }); $awaitable = Async\run(static fn() => $ks->waitFor('x', 'hello')); @@ -104,13 +105,13 @@ public function testOperationIsNotStartedIfLimitIsReached(): void $semaphore = new Async\KeyedSemaphore(1, static function (string $_, string $input) use ($spy): void { $spy->value[] = $input; - Async\sleep(0.002); + Async\sleep(DateTime\Duration::milliseconds(2)); }); Async\run(static fn() => $semaphore->waitFor('x', 'hello')); $awaitable = Async\run(static fn() => $semaphore->waitFor('x', 'world')); - Async\sleep(0.001); + Async\sleep(DateTime\Duration::milliseconds(1)); static::assertNotContains('world', $spy->value); @@ -137,7 +138,7 @@ public function testCancelPendingOperationsButNotTheOngoingOne(): void * @var Async\KeyedSemaphore */ $ks = new Async\KeyedSemaphore(1, static function (string $_, string $input): string { - Async\sleep(0.04); + Async\sleep(DateTime\Duration::milliseconds(40)); return $input; }); @@ -145,7 +146,7 @@ public function testCancelPendingOperationsButNotTheOngoingOne(): void $one = Async\run(static fn() => $ks->waitFor('foo', 'one')); $two = Async\run(static fn() => $ks->waitFor('foo', 'two')); - Async\sleep(0.01); + Async\sleep(DateTime\Duration::milliseconds(10)); $ks->cancel('foo', new Async\Exception\TimeoutException('The semaphore is destroyed.')); @@ -163,7 +164,7 @@ public function testCancelAllPendingOperations(): void * @var Async\KeyedSemaphore */ $ks = new Async\KeyedSemaphore(1, static function (string $_, string $input): string { - Async\sleep(0.04); + Async\sleep(DateTime\Duration::milliseconds(40)); return $input; }); @@ -180,7 +181,7 @@ public function testCancelAllPendingOperations(): void Async\run(static fn() => $ks->waitFor('baz', 'pending')) ]; - Async\sleep(0.01); + Async\sleep(DateTime\Duration::milliseconds(10)); $ks->cancelAll(new Async\Exception\TimeoutException('The semaphore is destroyed.')); @@ -203,7 +204,7 @@ public function testSemaphoreStatus(): void * @var Async\KeyedSemaphore */ $ks = new Async\KeyedSemaphore(1, static function (string $_, string $input): string { - Async\sleep(0.04); + Async\sleep(DateTime\Duration::milliseconds(40)); return $input; }); @@ -255,7 +256,7 @@ public function testWaitForRoom(): void * @var Async\KeyedSemaphore */ $ks = new Async\KeyedSemaphore(1, static function (string $_, string $input): string { - Async\sleep(0.04); + Async\sleep(DateTime\Duration::milliseconds(40)); return $input; }); @@ -273,7 +274,7 @@ public function testConcurrencyLimitOnDifferentKeys(): void * @var Async\KeyedSemaphore */ $ks = new Async\KeyedSemaphore(1, static function (string $_, string $input): string { - Async\sleep(0.04); + Async\sleep(DateTime\Duration::milliseconds(40)); return $input; }); static::assertSame(1, $ks->getConcurrencyLimit()); diff --git a/tests/unit/Async/KeyedSequenceTest.php b/tests/unit/Async/KeyedSequenceTest.php index 0be9737e..5f1074d1 100644 --- a/tests/unit/Async/KeyedSequenceTest.php +++ b/tests/unit/Async/KeyedSequenceTest.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\TestCase; use Psl; use Psl\Async; +use Psl\DateTime; final class KeyedSequenceTest extends TestCase { @@ -38,9 +39,9 @@ public function testSequenceOperationWaitsForPendingOperationsWhenLimitIsNotReac $spy->value[] = $data['value']; }); - Async\run(static fn() => $ks->waitFor('operation', ['time' => 0.003, 'value' => 'a'])); - Async\run(static fn() => $ks->waitFor('operation', ['time' => 0.004, 'value' => 'b'])); - Async\run(static fn() => $ks->waitFor('operation', ['time' => 0.005, 'value' => 'c'])); + Async\run(static fn() => $ks->waitFor('operation', ['time' => DateTime\Duration::milliseconds(3), 'value' => 'a'])); + Async\run(static fn() => $ks->waitFor('operation', ['time' => DateTime\Duration::milliseconds(4), 'value' => 'b'])); + Async\run(static fn() => $ks->waitFor('operation', ['time' => DateTime\Duration::milliseconds(5), 'value' => 'c'])); $last = Async\run(static fn() => $ks->waitFor('operation', ['time' => null, 'value' => 'd'])); $last->await(); @@ -54,10 +55,12 @@ public function testOperationIsStartedIfLimitIsNotReached(): void /** * @var Async\KeyedSequence */ - $ks = new Async\KeyedSequence(static function (string $_, string $input) use ($spy): void { + $ks = new Async\KeyedSequence(static function (string $key, string $input) use ($spy): void { + static::assertSame('x', $key); + $spy->value[] = $input; - Async\sleep(0.002); + Async\sleep(DateTime\Duration::milliseconds(2)); }); $awaitable = Async\run(static fn() => $ks->waitFor('x', 'hello')); @@ -79,13 +82,13 @@ public function testOperationIsNotStartedIfLimitIsReached(): void $ks = new Async\KeyedSequence(static function (string $_, string $input) use ($spy): void { $spy->value[] = $input; - Async\sleep(0.002); + Async\sleep(DateTime\Duration::milliseconds(2)); }); Async\run(static fn() => $ks->waitFor('x', 'hello')); $awaitable = Async\run(static fn() => $ks->waitFor('x', 'world')); - Async\sleep(0.001); + Async\sleep(DateTime\Duration::milliseconds(1)); static::assertNotContains('world', $spy->value); @@ -112,7 +115,7 @@ public function testCancelPendingOperationsButNotTheOngoingOne(): void * @var Async\KeyedSequence */ $ks = new Async\KeyedSequence(static function (string $_, string $input): string { - Async\sleep(0.04); + Async\sleep(DateTime\Duration::milliseconds(40)); return $input; }); @@ -120,7 +123,7 @@ public function testCancelPendingOperationsButNotTheOngoingOne(): void $one = Async\run(static fn() => $ks->waitFor('foo', 'one')); $two = Async\run(static fn() => $ks->waitFor('foo', 'two')); - Async\sleep(0.01); + Async\sleep(DateTime\Duration::milliseconds(10)); $ks->cancel('foo', new Async\Exception\TimeoutException('The semaphore is destroyed.')); @@ -138,7 +141,7 @@ public function testCancelAllPendingOperations(): void * @var Async\KeyedSequence */ $ks = new Async\KeyedSequence(static function (string $_, string $input): string { - Async\sleep(0.04); + Async\sleep(DateTime\Duration::milliseconds(40)); return $input; }); @@ -155,7 +158,7 @@ public function testCancelAllPendingOperations(): void Async\run(static fn() => $ks->waitFor('baz', 'pending')) ]; - Async\sleep(0.01); + Async\sleep(DateTime\Duration::milliseconds(10)); $ks->cancelAll(new Async\Exception\TimeoutException('The semaphore is destroyed.')); @@ -178,7 +181,7 @@ public function testSemaphoreStatus(): void * @var Async\KeyedSequence */ $ks = new Async\KeyedSequence(static function (string $_, string $input): string { - Async\sleep(0.04); + Async\sleep(DateTime\Duration::milliseconds(40)); return $input; }); @@ -226,7 +229,8 @@ public function testWaitForRoom(): void * @var Async\KeyedSequence */ $ks = new Async\KeyedSequence(static function (string $_, string $input): string { - Async\sleep(0.04); + Async\sleep(DateTime\Duration::milliseconds(40)); + return $input; }); @@ -244,7 +248,8 @@ public function testConcurrencyLimitOnDifferentKeys(): void * @var Async\KeyedSequence */ $ks = new Async\KeyedSequence(static function (string $_, string $input): string { - Async\sleep(0.04); + Async\sleep(DateTime\Duration::milliseconds(40)); + return $input; }); diff --git a/tests/unit/Async/ParallelTest.php b/tests/unit/Async/ParallelTest.php index faac7241..b90fe5fc 100644 --- a/tests/unit/Async/ParallelTest.php +++ b/tests/unit/Async/ParallelTest.php @@ -8,6 +8,7 @@ use PHPUnit\Util\Exception; use Psl; use Psl\Async; +use Psl\DateTime; final class ParallelTest extends TestCase { @@ -17,17 +18,17 @@ public function testParallel(): void Async\concurrently([ static function () use ($spy): void { - Async\sleep(0.03); + Async\sleep(DateTime\Duration::milliseconds(30)); $spy->value .= '1'; }, static function () use ($spy): void { - Async\sleep(0.01); + Async\sleep(DateTime\Duration::milliseconds(10)); $spy->value .= '2'; }, static function () use ($spy): void { - Async\sleep(0.01); + Async\sleep(DateTime\Duration::milliseconds(10)); $spy->value .= '3'; }, @@ -45,12 +46,12 @@ public function testParallelThrowsForTheFirstAndDoesNotCallTheRest(): void try { Async\concurrently([ static function (): void { - Async\sleep(0.003); + Async\sleep(DateTime\Duration::milliseconds(3)); throw new Exception('foo'); }, static function () use ($spy): void { - Async\sleep(0.004); + Async\sleep(DateTime\Duration::milliseconds(4)); $spy->value = 'thrown'; diff --git a/tests/unit/Async/ReflectTest.php b/tests/unit/Async/ReflectTest.php index 94e9f979..37609c84 100644 --- a/tests/unit/Async/ReflectTest.php +++ b/tests/unit/Async/ReflectTest.php @@ -7,6 +7,7 @@ use Exception; use PHPUnit\Framework\TestCase; use Psl\Async; +use Psl\DateTime; use Psl\Result; final class ReflectTest extends TestCase @@ -15,7 +16,7 @@ public function testReflectParallel(): void { [$one, $two] = Async\concurrently([ Result\reflect(static function (): void { - Async\sleep(0.0001); + Async\sleep(DateTime\Duration::milliseconds(1)); throw new Exception('failure'); }), diff --git a/tests/unit/Async/RunTest.php b/tests/unit/Async/RunTest.php index 671c3d3d..8fdba0da 100644 --- a/tests/unit/Async/RunTest.php +++ b/tests/unit/Async/RunTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\TestCase; use Psl\Async; +use Psl\DateTime; final class RunTest extends TestCase { @@ -13,9 +14,9 @@ public function testRun(): void { $awaitable = Async\run(static function (): string { Async\concurrently([ - static fn() => Async\sleep(0.001), - static fn() => Async\sleep(0.001), - static fn() => Async\sleep(0.001), + static fn() => Async\sleep(DateTime\Duration::milliseconds(1)), + static fn() => Async\sleep(DateTime\Duration::milliseconds(1)), + static fn() => Async\sleep(DateTime\Duration::milliseconds(1)), ]); return 'hello'; diff --git a/tests/unit/Async/SemaphoreTest.php b/tests/unit/Async/SemaphoreTest.php index c8168196..5b7443d8 100644 --- a/tests/unit/Async/SemaphoreTest.php +++ b/tests/unit/Async/SemaphoreTest.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\TestCase; use Psl; use Psl\Async; +use Psl\DateTime; final class SemaphoreTest extends TestCase { @@ -24,7 +25,7 @@ public function testSequenceOperationWaitsForPendingOperationsWhenLimitIsNotReac $spy = new Psl\Ref([]); /** - * @var Async\Semaphore + * @var Async\Semaphore */ $semaphore = new Async\Semaphore(1, static function (array $data) use ($spy): void { if ($data['time'] !== null) { @@ -34,9 +35,9 @@ public function testSequenceOperationWaitsForPendingOperationsWhenLimitIsNotReac $spy->value[] = $data['value']; }); - Async\run(static fn() => $semaphore->waitFor(['time' => 0.003, 'value' => 'a'])); - Async\run(static fn() => $semaphore->waitFor(['time' => 0.004, 'value' => 'b'])); - Async\run(static fn() => $semaphore->waitFor(['time' => 0.005, 'value' => 'c'])); + Async\run(static fn() => $semaphore->waitFor(['time' => DateTime\Duration::milliseconds(3), 'value' => 'a'])); + Async\run(static fn() => $semaphore->waitFor(['time' => DateTime\Duration::milliseconds(4), 'value' => 'b'])); + Async\run(static fn() => $semaphore->waitFor(['time' => DateTime\Duration::milliseconds(5), 'value' => 'c'])); $last = Async\run(static fn() => $semaphore->waitFor(['time' => null, 'value' => 'd'])); $last->await(); @@ -48,7 +49,7 @@ public function testOperationWaitsForPendingOperationsWhenLimitIsNotReached(): v $spy = new Psl\Ref([]); /** - * @var Async\Semaphore + * @var Async\Semaphore */ $semaphore = new Async\Semaphore(2, static function (array $data) use ($spy): void { if ($data['time'] !== null) { @@ -58,9 +59,9 @@ public function testOperationWaitsForPendingOperationsWhenLimitIsNotReached(): v $spy->value[] = $data['value']; }); - Async\run(static fn() => $semaphore->waitFor(['time' => 0.003, 'value' => 'a'])); - Async\run(static fn() => $semaphore->waitFor(['time' => 0.004, 'value' => 'b'])); - $beforeLast = Async\run(static fn() => $semaphore->waitFor(['time' => 0.005, 'value' => 'c'])); + Async\run(static fn() => $semaphore->waitFor(['time' => Datetime\Duration::milliseconds(3), 'value' => 'a'])); + Async\run(static fn() => $semaphore->waitFor(['time' => Datetime\Duration::milliseconds(4), 'value' => 'b'])); + $beforeLast = Async\run(static fn() => $semaphore->waitFor(['time' => Datetime\Duration::milliseconds(5), 'value' => 'c'])); Async\run(static fn() => $semaphore->waitFor(['time' => null, 'value' => 'd'])); $beforeLast->await(); @@ -78,12 +79,12 @@ public function testOperationIsStartedIfLimitIsNotReached(): void $semaphore = new Async\Semaphore(1, static function (string $input) use ($spy): void { $spy->value[] = $input; - Async\sleep(0.002); + Async\sleep(Datetime\Duration::milliseconds(2)); }); $awaitable = Async\run(static fn() => $semaphore->waitFor('hello')); - Async\sleep(0.001); + Async\sleep(Datetime\Duration::milliseconds(1)); static::assertSame(['hello'], $spy->value); @@ -100,13 +101,13 @@ public function testOperationIsNotStartedIfLimitIsReached(): void $semaphore = new Async\Semaphore(1, static function (string $input) use ($spy): void { $spy->value[] = $input; - Async\sleep(0.002); + Async\sleep(Datetime\Duration::milliseconds(2)); }); Async\run(static fn() => $semaphore->waitFor('hello')); $awaitable = Async\run(static fn() => $semaphore->waitFor('world')); - Async\sleep(0.001); + Async\sleep(Datetime\Duration::milliseconds(1)); static::assertNotContains('world', $spy->value); @@ -133,7 +134,7 @@ public function testCancelPendingOperationsButNotTheOngoingOne(): void * @var Async\Semaphore */ $semaphore = new Async\Semaphore(1, static function (string $input): string { - Async\sleep(0.04); + Async\sleep(Datetime\Duration::milliseconds(40)); return $input; }); @@ -143,7 +144,7 @@ public function testCancelPendingOperationsButNotTheOngoingOne(): void $one = Async\run(static fn() => $semaphore->waitFor('one')); $two = Async\run(static fn() => $semaphore->waitFor('two')); - Async\sleep(0.01); + Async\sleep(Datetime\Duration::milliseconds(10)); $semaphore->cancel(new Async\Exception\TimeoutException('The semaphore is destroyed.')); @@ -161,7 +162,7 @@ public function testSemaphoreStatus(): void * @var Async\Semaphore */ $semaphore = new Async\Semaphore(1, static function (string $input): string { - Async\sleep(0.04); + Async\sleep(Datetime\Duration::milliseconds(40)); return $input; }); @@ -195,7 +196,7 @@ public function testWaitForPending(): void * @var Async\Semaphore */ $semaphore = new Async\Semaphore(1, static function (string $input): string { - Async\sleep(0.04); + Async\sleep(Datetime\Duration::milliseconds(40)); return $input; }); diff --git a/tests/unit/Async/SequenceTest.php b/tests/unit/Async/SequenceTest.php index c10c6803..a3fb9dc5 100644 --- a/tests/unit/Async/SequenceTest.php +++ b/tests/unit/Async/SequenceTest.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\TestCase; use Psl; use Psl\Async; +use Psl\DateTime; use Psl\Str; use function microtime; @@ -37,9 +38,9 @@ public function testSequenceOperationWaitsForPendingOperationsWhenLimitIsNotReac $spy->value[] = $data['value']; }); - Async\run(static fn() => $sequence->waitFor(['time' => 0.003, 'value' => 'a'])); - Async\run(static fn() => $sequence->waitFor(['time' => 0.004, 'value' => 'b'])); - Async\run(static fn() => $sequence->waitFor(['time' => 0.005, 'value' => 'c'])); + Async\run(static fn() => $sequence->waitFor(['time' => DateTime\Duration::milliseconds(3), 'value' => 'a'])); + Async\run(static fn() => $sequence->waitFor(['time' => DateTime\Duration::milliseconds(4), 'value' => 'b'])); + Async\run(static fn() => $sequence->waitFor(['time' => DateTime\Duration::milliseconds(5), 'value' => 'c'])); $last = Async\run(static fn() => $sequence->waitFor(['time' => null, 'value' => 'd'])); $last->await(); @@ -56,12 +57,12 @@ public function testOperationIsStartedIfLimitIsNotReached(): void $sequence = new Async\Sequence(static function (string $input) use ($spy): void { $spy->value[] = $input; - Async\sleep(0.002); + Async\sleep(DateTime\Duration::milliseconds(2)); }); $awaitable = Async\run(static fn() => $sequence->waitFor('hello')); - Async\sleep(0.001); + Async\sleep(DateTime\Duration::milliseconds(1)); static::assertSame(['hello'], $spy->value); @@ -78,13 +79,13 @@ public function testOperationIsNotStartedIfLimitIsReached(): void $sequence = new Async\Sequence(static function (string $input) use ($spy): void { $spy->value[] = $input; - Async\sleep(0.002); + Async\sleep(DateTime\Duration::milliseconds(2)); }); Async\run(static fn() => $sequence->waitFor('hello')); $awaitable = Async\run(static fn() => $sequence->waitFor('world')); - Async\sleep(0.001); + Async\sleep(DateTime\Duration::milliseconds(1)); static::assertNotContains('world', $spy->value); @@ -111,7 +112,7 @@ public function testCancelPendingOperationsButNotTheOngoingOne(): void * @var Async\Sequence */ $sequence = new Async\Sequence(static function (string $input): string { - Async\sleep(0.04); + Async\sleep(DateTime\Duration::milliseconds(40)); return $input; }); @@ -119,7 +120,7 @@ public function testCancelPendingOperationsButNotTheOngoingOne(): void $one = Async\run(static fn() => $sequence->waitFor('one')); $two = Async\run(static fn() => $sequence->waitFor('two')); - Async\sleep(0.01); + Async\sleep(DateTime\Duration::milliseconds(10)); $sequence->cancel(new Async\Exception\TimeoutException('The semaphore is destroyed.')); @@ -138,8 +139,8 @@ public function testBug327(): void { $ref = new Psl\Ref(''); - $sequence = new Async\Sequence(static function (float $value) use ($ref): void { - $ref->value .= Str\format('%f', $value); + $sequence = new Async\Sequence(static function (DateTime\Duration $value) use ($ref): void { + $ref->value .= Str\format('%f', $value->getTotalSeconds()); Async\sleep($value); }); @@ -148,10 +149,10 @@ public function testBug327(): void Async\concurrently([ static function () use ($sequence): void { - $sequence->waitFor(0.02); - $sequence->waitFor(0.02); + $sequence->waitFor(DateTime\Duration::milliseconds(20)); + $sequence->waitFor(DateTime\Duration::milliseconds(20)); }, - static fn() => $sequence->waitFor(0.02), + static fn() => $sequence->waitFor(DateTime\Duration::milliseconds(20)), ]); $duration = microtime(true) - $time; @@ -166,7 +167,7 @@ public function testStatus(): void * @var Async\Sequence */ $s = new Async\Sequence(static function (string $input): string { - Async\sleep(0.04); + Async\sleep(DateTime\Duration::milliseconds(40)); return $input; }); @@ -196,7 +197,7 @@ public function testWaitForPending(): void * @var Async\Sequence */ $s = new Async\Sequence(static function (string $input): string { - Async\sleep(0.04); + Async\sleep(DateTime\Duration::milliseconds(40)); return $input; }); diff --git a/tests/unit/Async/SeriesTest.php b/tests/unit/Async/SeriesTest.php index 27049e27..30325893 100644 --- a/tests/unit/Async/SeriesTest.php +++ b/tests/unit/Async/SeriesTest.php @@ -8,6 +8,7 @@ use PHPUnit\Util\Exception; use Psl; use Psl\Async; +use Psl\DateTime; final class SeriesTest extends TestCase { @@ -17,17 +18,17 @@ public function testSeries(): void Async\series([ static function () use ($spy): void { - Async\sleep(0.003); + Async\sleep(DateTime\Duration::milliseconds(3)); $spy->value .= '1'; }, static function () use ($spy): void { - Async\sleep(0.001); + Async\sleep(DateTime\Duration::milliseconds(1)); $spy->value .= '2'; }, static function () use ($spy): void { - Async\sleep(0.001); + Async\sleep(DateTime\Duration::milliseconds(1)); $spy->value .= '3'; }, @@ -45,7 +46,7 @@ public function testSeriesThrowsForTheFirstAndDoesNotCallTheRest(): void try { Async\series([ static function (): void { - Async\sleep(0.003); + Async\sleep(DateTime\Duration::milliseconds(3)); throw new Exception('foo'); }, diff --git a/tests/unit/Channel/BoundedChannelTest.php b/tests/unit/Channel/BoundedChannelTest.php index 20dab41e..5242e9f5 100644 --- a/tests/unit/Channel/BoundedChannelTest.php +++ b/tests/unit/Channel/BoundedChannelTest.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\TestCase; use Psl\Async; use Psl\Channel; +use Psl\DateTime; final class BoundedChannelTest extends TestCase { @@ -109,10 +110,9 @@ public function testIsFull(): void public function testTrySendThrowsOnFullChannel(): void { /** - * @var Channel\ReceiverInterface $receiver * @var Channel\SenderInterface $sender */ - [$receiver, $sender] = Channel\bounded(1); + [$_, $sender] = Channel\bounded(1); $sender->send('hello'); @@ -131,7 +131,7 @@ public function testSendWaitsForFullChannel(): void $sender->send('hello'); - Async\Scheduler::delay(0.001, static function () use ($receiver) { + Async\Scheduler::delay(DateTime\Duration::milliseconds(1), static function () use ($receiver) { $receiver->receive(); }); @@ -166,7 +166,7 @@ public function testSendThrowsForLateClosedChannel(): void $sender->send('hello'); - Async\Scheduler::delay(0.001, static function () use ($receiver): void { + Async\Scheduler::delay(DateTime\Duration::milliseconds(1), static function () use ($receiver): void { $receiver->close(); }); @@ -233,7 +233,7 @@ public function testReceiveThrowsForLateClosedChannel(): void */ [$receiver, $sender] = Channel\bounded(1); - Async\Scheduler::delay(0.0001, static function () use ($sender): void { + Async\Scheduler::delay(DateTime\Duration::milliseconds(1), static function () use ($sender): void { $sender->close(); }); @@ -267,9 +267,10 @@ public function testReceiveWaitsWhenChannelIsEmpty(): void */ [$receiver, $sender] = Channel\bounded(1); - Async\Scheduler::delay(0.001, static function () use ($sender) { - $sender->send('hello'); - }); + Async\Scheduler::delay( + DateTime\Duration::milliseconds(1), + static fn() => $sender->send('hello'), + ); static::assertTrue($receiver->isEmpty()); @@ -280,9 +281,8 @@ public function testTryReceiveThrowsForEmptyChannel(): void { /** * @var Channel\ReceiverInterface $receiver - * @var Channel\SenderInterface $sender */ - [$receiver, $sender] = Channel\bounded(1); + [$receiver, $_] = Channel\bounded(1); $this->expectException(Channel\Exception\EmptyChannelException::class); $this->expectExceptionMessage('Attempted to receiver from an empty channel.'); diff --git a/tests/unit/DateTime/DateTimeTest.php b/tests/unit/DateTime/DateTimeTest.php new file mode 100644 index 00000000..5bc1f399 --- /dev/null +++ b/tests/unit/DateTime/DateTimeTest.php @@ -0,0 +1,61 @@ +getTimestamp(); + + static::assertEqualsWithDelta(time(), $timestamp->getSeconds(), 1); + } + + public function testTodayAt(): void + { + $now = DateTime::now(); + $today = DateTime::todayAt(14, 00, 00); + + static::assertSame($now->getDate(), $today->getDate()); + static::assertNotSame($now->getTime(), $today->getTime()); + static::assertSame(14, $today->getHours()); + static::assertSame(0, $today->getMinutes()); + static::assertSame(0, $today->getSeconds()); + } + + public function testFromParts(): void + { + $datetime = DateTime::fromParts(Timezone::UTC, 2024, Month::February, 4, 14, 0, 0, 1); + + static::assertSame(Timezone::UTC, $datetime->getTimezone()); + static::assertSame(2024, $datetime->getYear()); + static::assertSame(2, $datetime->getMonth()); + static::assertSame(4, $datetime->getDay()); + static::assertSame(Weekday::Sunday, $datetime->getWeekday()); + static::assertSame(14, $datetime->getHours()); + static::assertSame(0, $datetime->getMinutes()); + static::assertSame(0, $datetime->getSeconds()); + static::assertSame(1, $datetime->getNanoseconds()); + } + + public function testFromPartsWithInvalidComponent(): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Unexpected hours value encountered. Provided "999", but the calendar expects "15". Ensure the hour falls within a 24-hour day.'); + + DateTime::fromParts(Timezone::UTC, 2024, Month::February, 4, 999, 0, 0, 1); + } +} diff --git a/tests/unit/DateTime/DateTimeTestTrait.php b/tests/unit/DateTime/DateTimeTestTrait.php new file mode 100644 index 00000000..20a6febd --- /dev/null +++ b/tests/unit/DateTime/DateTimeTestTrait.php @@ -0,0 +1,26 @@ +timezone = date_default_timezone_get(); + $this->locale = locale_get_default(); + + date_default_timezone_set('Europe/London'); + locale_set_default('en_GB'); + } + + protected function tearDown(): void + { + date_default_timezone_set($this->timezone); + locale_set_default($this->locale); + } +} diff --git a/tests/unit/DateTime/DurationTest.php b/tests/unit/DateTime/DurationTest.php new file mode 100644 index 00000000..0e76acc5 --- /dev/null +++ b/tests/unit/DateTime/DurationTest.php @@ -0,0 +1,353 @@ +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 [ + [DateTime\Duration::hours(1), DateTime\Duration::minutes(42), Order::Greater], + [DateTime\Duration::minutes(2), DateTime\Duration::seconds(120), Order::Equal], + [DateTime\Duration::zero(), DateTime\Duration::nanoseconds(1), Order::Less], + ]; + } + /** + * @dataProvider provideCompare + */ + public function testCompare(DateTime\Duration $a, DateTime\Duration $b, Order $expected): void + { + $opposite = Order::from(-$expected->value); + + static::assertEquals($expected, $a->compare($b)); + static::assertEquals($opposite, $b->compare($a)); + static::assertEquals($expected === Order::Equal, $a->equals($b)); + static::assertEquals($expected === Order::Less, $a->shorter($b)); + static::assertEquals($expected !== Order::Greater, $a->shorterOrEqual($b)); + static::assertEquals($expected === Order::Greater, $a->longer($b)); + static::assertEquals($expected !== Order::Less, $a->longerOrEqual($b)); + static::assertFalse($a->betweenExclusive($a, $a)); + static::assertFalse($a->betweenExclusive($a, $b)); + static::assertFalse($a->betweenExclusive($b, $a)); + static::assertFalse($a->betweenExclusive($b, $b)); + static::assertTrue($a->betweenInclusive($a, $a)); + static::assertTrue($a->betweenInclusive($a, $b)); + static::assertTrue($a->betweenInclusive($b, $a)); + static::assertEquals($expected === Order::Equal, $a->betweenInclusive($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->betweenExclusive($a, $c)); + static::assertTrue($b->betweenExclusive($c, $a)); + static::assertTrue($b->betweenInclusive($a, $c)); + static::assertTrue($b->betweenInclusive($c, $a)); + static::assertFalse($a->betweenExclusive($b, $c)); + static::assertFalse($a->betweenInclusive($c, $b)); + static::assertFalse($c->betweenInclusive($a, $b)); + static::assertFalse($c->betweenExclusive($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->invert()->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); + } +} diff --git a/tests/unit/DateTime/EraTest.php b/tests/unit/DateTime/EraTest.php new file mode 100644 index 00000000..aadea030 --- /dev/null +++ b/tests/unit/DateTime/EraTest.php @@ -0,0 +1,36 @@ +toggle()); + static::assertSame(Era::BeforeChrist, Era::AnnoDomini->toggle()); + } +} diff --git a/tests/unit/DateTime/IsLeapYearTest.php b/tests/unit/DateTime/IsLeapYearTest.php new file mode 100644 index 00000000..6dac23b6 --- /dev/null +++ b/tests/unit/DateTime/IsLeapYearTest.php @@ -0,0 +1,35 @@ +toggle()); + static::assertSame(Meridiem::AnteMeridiem, Meridiem::PostMeridiem->toggle()); + } +} diff --git a/tests/unit/DateTime/TimestampTest.php b/tests/unit/DateTime/TimestampTest.php new file mode 100644 index 00000000..de80f43a --- /dev/null +++ b/tests/unit/DateTime/TimestampTest.php @@ -0,0 +1,406 @@ +getSeconds(), 1); + } + + public function testMonotonic(): void + { + $timestamp = Timestamp::monotonic(); + + static::assertEqualsWithDelta(time(), $timestamp->getSeconds(), 1); + } + + public function testMonotonicIsPrecise(): void + { + $a = Timestamp::monotonic(); + + Async\sleep(Duration::milliseconds(100)); + + $b = Timestamp::monotonic(); + + $difference = $b->since($a); + + static::assertGreaterThan(100.0, $difference->getTotalMilliseconds()); + } + + public function testFromRowOverflow(): void + { + $this->expectException(OverflowException::class); + $this->expectExceptionMessage('Adding nanoseconds would cause an overflow.'); + + Timestamp::fromParts(Math\INT64_MAX, NANOSECONDS_PER_SECOND); + } + + public function testFromRowUnderflow(): void + { + $this->expectException(UnderflowException::class); + $this->expectExceptionMessage('Subtracting nanoseconds would cause an underflow.'); + + Timestamp::fromParts(Math\INT64_MIN, -NANOSECONDS_PER_SECOND); + } + + public function testFromRowSimplifiesNanoseconds(): void + { + $timestamp = Timestamp::fromParts(0, NANOSECONDS_PER_SECOND * 20); + + static::assertEquals(20, $timestamp->getSeconds()); + static::assertEquals(0, $timestamp->getNanoseconds()); + + $timestamp = Timestamp::fromParts(0, 100 + NANOSECONDS_PER_SECOND * 20); + + static::assertEquals(20, $timestamp->getSeconds()); + static::assertEquals(100, $timestamp->getNanoseconds()); + + $timestamp = Timestamp::fromParts(30, -NANOSECONDS_PER_SECOND * 20); + + static::assertEquals(10, $timestamp->getSeconds()); + static::assertEquals(0, $timestamp->getNanoseconds()); + + $timestamp = Timestamp::fromParts(10, 100 + -NANOSECONDS_PER_SECOND * 20); + + static::assertEquals(-10, $timestamp->getSeconds()); + static::assertEquals(100, $timestamp->getNanoseconds()); + } + + public function testParsingFromPattern(): void + { + $timestamp = Timestamp::parse( + raw_string: '2024 091', + pattern: FormatPattern::JulianDay, + ); + + $datetime = DateTime::fromTimestamp($timestamp, Timezone::UTC); + + static::assertSame(2024, $datetime->getYear()); + static::assertSame(3, $datetime->getMonth()); + static::assertSame(31, $datetime->getDay()); + } + + public function testFromPatternFails(): void + { + $this->expectException(ParserException::class); + $this->expectExceptionMessage('Unable to interpret \'2\' as a valid date/time using pattern \'yyyy DDD\'.'); + + Timestamp::parse('2', pattern: FormatPattern::JulianDay); + } + + public function testParseFormat(): void + { + $a = Timestamp::now(); + $string = $a->format(); + + $b = Timestamp::parse($string); + + static::assertSame($a->getSeconds(), $b->getSeconds()); + } + + public function testFromStringToString(): void + { + $a = Timestamp::now(); + $string = $a->toString(); + + $b = Timestamp::fromString($string); + + static::assertSame($a->getSeconds(), $b->getSeconds()); + } + + public function testParseFails(): void + { + $this->expectException(ParserException::class); + $this->expectExceptionMessage('Unable to interpret \'x\' as a valid date/time.'); + + Timestamp::parse('x'); + } + + public function provideFormatParsingData(): iterable + { + yield [1711917897, FormatPattern::FullDateTime, Timezone::UTC, Locale::English, 'Sunday, March 31, 2024 20:44:57']; + yield [1711917897, FormatPattern::FullDateTime, Timezone::AsiaShanghai, Locale::ChineseTraditional, '星期一, 4月 01, 2024 04:44:57']; + yield [1711917897, FormatPattern::Cookie, Timezone::AmericaNewYork, Locale::EnglishUnitedStates, 'Sunday, 31-Mar-2024 16:44:57 EDT']; + yield [1711917897, FormatPattern::Http, Timezone::EuropeVienna, Locale::GermanAustria, 'So., 31 März 2024 22:44:57 MESZ']; + yield [1711917897, FormatPattern::Email, Timezone::EuropeMadrid, Locale::SpanishSpain, 'dom, 31 mar 2024 22:44:57 GMT+02:00']; + yield [1711917897, FormatPattern::SqlDateTime, Timezone::AfricaTunis, Locale::ArabicTunisia, '2024-03-31 21:44:57']; + yield [1711832400, FormatPattern::IsoOrdinalDate, Timezone::EuropeMoscow, Locale::RussianRussia, '2024-091']; + yield [1711917897, FormatPattern::Iso8601, Timezone::EuropeLondon, Locale::EnglishUnitedKingdom, '2024-03-31T21:44:57.000+01:00']; + } + + /** + * @dataProvider provideFormatParsingData + */ + public function testFormattingAndPatternParsing(int $timestamp, string|FormatPattern $pattern, Timezone $timezone, Locale $locale, string $expected): void + { + $timestamp = Timestamp::fromParts($timestamp); + + $result = $timestamp->format(pattern: $pattern, timezone: $timezone, locale: $locale); + + static::assertSame($expected, $result); + + $other = Timestamp::parse($result, pattern: $pattern, timezone: $timezone, locale: $locale); + + static::assertSame($timestamp->getSeconds(), $other->getSeconds()); + static::assertSame($timestamp->getNanoseconds(), $other->getNanoseconds()); + } + + public function testToRaw(): void + { + $timestamp = Timestamp::fromParts(12, 10); + $parts = $timestamp->toParts(); + + static::assertSame(12, $parts[0]); + static::assertSame(10, $parts[1]); + } + + /** + * @return list + */ + public static function provideCompare(): array + { + return [ + [Timestamp::fromParts(100), Timestamp::fromParts(42), Order::Greater], + [Timestamp::fromParts(42), Timestamp::fromParts(42), Order::Equal], + [Timestamp::fromParts(42), Timestamp::fromParts(100), Order::Less], + ]; + } + /** + * @dataProvider provideCompare + */ + public function testCompare(Timestamp $a, Timestamp $b, Order $expected): void + { + $opposite = Order::from(-$expected->value); + + static::assertEquals($expected, $a->compare($b)); + static::assertEquals($opposite, $b->compare($a)); + static::assertEquals($expected === Order::Equal, $a->equals($b)); + static::assertEquals($expected === Order::Less, $a->before($b)); + static::assertEquals($expected !== Order::Greater, $a->beforeOrAtTheSameTime($b)); + static::assertEquals($expected === Order::Greater, $a->after($b)); + static::assertEquals($expected !== Order::Less, $a->afterOrAtTheSameTime($b)); + static::assertFalse($a->betweenTimeExclusive($a, $a)); + static::assertFalse($a->betweenTimeExclusive($a, $b)); + static::assertFalse($a->betweenTimeExclusive($b, $a)); + static::assertFalse($a->betweenTimeExclusive($b, $b)); + static::assertTrue($a->betweenTimeInclusive($a, $a)); + static::assertTrue($a->betweenTimeInclusive($a, $b)); + static::assertTrue($a->betweenTimeInclusive($b, $a)); + static::assertEquals($expected === Order::Equal, $a->betweenTimeInclusive($b, $b)); + } + + public function testNanosecondsModifications(): void + { + $timestamp = Timestamp::fromParts(0, 100); + + static::assertSame(100, $timestamp->getNanoseconds()); + + $timestamp = $timestamp->plus(Duration::nanoseconds(10)); + + static::assertSame(110, $timestamp->getNanoseconds()); + + $timestamp = $timestamp->plus(Duration::nanoseconds(-10)); + + static::assertSame(100, $timestamp->getNanoseconds()); + + $timestamp = $timestamp->minus(Duration::nanoseconds(-10)); + + static::assertSame(110, $timestamp->getNanoseconds()); + + $timestamp = $timestamp->minus(Duration::nanoseconds(10)); + + static::assertSame(100, $timestamp->getNanoseconds()); + + $timestamp = $timestamp->plusNanoseconds(10); + + static::assertSame(110, $timestamp->getNanoseconds()); + + $timestamp = $timestamp->plusNanoseconds(-10); + + static::assertSame(100, $timestamp->getNanoseconds()); + + $timestamp = $timestamp->minusNanoseconds(-10); + + static::assertSame(110, $timestamp->getNanoseconds()); + + $timestamp = $timestamp->minusNanoseconds(10); + + static::assertSame(100, $timestamp->getNanoseconds()); + } + + public function testSecondsModifications(): void + { + $timestamp = Timestamp::fromParts(5); + + static::assertSame(5, $timestamp->getSeconds()); + + $timestamp = $timestamp->plus(Duration::seconds(1)); + + static::assertSame(6, $timestamp->getSeconds()); + + $timestamp = $timestamp->plus(Duration::seconds(-1)); + + static::assertSame(5, $timestamp->getSeconds()); + + $timestamp = $timestamp->minus(Duration::seconds(-1)); + + static::assertSame(6, $timestamp->getSeconds()); + + $timestamp = $timestamp->minus(Duration::seconds(1)); + + static::assertSame(5, $timestamp->getSeconds()); + + $timestamp = $timestamp->plusSeconds(1); + + static::assertSame(6, $timestamp->getSeconds()); + + $timestamp = $timestamp->plusSeconds(-1); + + static::assertSame(5, $timestamp->getSeconds()); + + $timestamp = $timestamp->minusSeconds(-1); + + static::assertSame(6, $timestamp->getSeconds()); + + $timestamp = $timestamp->minusSeconds(1); + + static::assertSame(5, $timestamp->getSeconds()); + } + + public function testMinuteModifications(): void + { + $timestamp = Timestamp::fromParts(5); + + static::assertSame(5, $timestamp->getSeconds()); + + $timestamp = $timestamp->plus(Duration::minutes(1)); + + static::assertSame(65, $timestamp->getSeconds()); + + $timestamp = $timestamp->plus(Duration::minutes(-1)); + + static::assertSame(5, $timestamp->getSeconds()); + + $timestamp = $timestamp->minus(Duration::minutes(-1)); + + static::assertSame(65, $timestamp->getSeconds()); + + $timestamp = $timestamp->minus(Duration::minutes(1)); + + static::assertSame(5, $timestamp->getSeconds()); + + $timestamp = $timestamp->plusMinutes(1); + + static::assertSame(65, $timestamp->getSeconds()); + + $timestamp = $timestamp->plusMinutes(-1); + + static::assertSame(5, $timestamp->getSeconds()); + + $timestamp = $timestamp->minusMinutes(-1); + + static::assertSame(65, $timestamp->getSeconds()); + + $timestamp = $timestamp->minusMinutes(1); + + static::assertSame(5, $timestamp->getSeconds()); + } + + public function testHourModifications(): void + { + $timestamp = Timestamp::fromParts(5); + + static::assertSame(5, $timestamp->getSeconds()); + + $timestamp = $timestamp->plus(Duration::hours(1)); + + static::assertSame(3605, $timestamp->getSeconds()); + + $timestamp = $timestamp->plus(Duration::hours(-1)); + + static::assertSame(5, $timestamp->getSeconds()); + + $timestamp = $timestamp->minus(Duration::hours(-1)); + + static::assertSame(3605, $timestamp->getSeconds()); + + $timestamp = $timestamp->minus(Duration::hours(1)); + + static::assertSame(5, $timestamp->getSeconds()); + + $timestamp = $timestamp->plusHours(1); + + static::assertSame(3605, $timestamp->getSeconds()); + + $timestamp = $timestamp->plusHours(-1); + + static::assertSame(5, $timestamp->getSeconds()); + + $timestamp = $timestamp->minusHours(-1); + + static::assertSame(3605, $timestamp->getSeconds()); + + $timestamp = $timestamp->minusHours(1); + + static::assertSame(5, $timestamp->getSeconds()); + } + + public function testConvertToTimezone(): void + { + $timestamp = Timestamp::fromParts(1711917232, 501_000_000); + + static::assertSame( + '2024-03-31T20:33:52.501Z', + $timestamp->convertToTimezone(Timezone::UTC)->format(pattern: FormatPattern::Iso8601), + ); + + static::assertSame( + '2024-03-31T21:33:52.501+01:00', + $timestamp->convertToTimezone(Timezone::AfricaTunis)->format(pattern: FormatPattern::Iso8601), + ); + + static::assertSame( + '2024-03-31T16:33:52.501-04:00', + $timestamp->convertToTimezone(Timezone::AmericaNewYork)->format(pattern: FormatPattern::Iso8601), + ); + + static::assertSame( + '2024-04-01T04:33:52.501+08:00', + $timestamp->convertToTimezone(Timezone::AsiaShanghai)->format(pattern: FormatPattern::Iso8601), + ); + } + + public function testJsonSerialization(): void + { + $serialized = Timestamp::fromParts(1711917232, 12)->jsonSerialize(); + + static::assertSame(1711917232, $serialized['seconds']); + static::assertSame(12, $serialized['nanoseconds']); + } +} diff --git a/tests/unit/DateTime/WeekdayTest.php b/tests/unit/DateTime/WeekdayTest.php new file mode 100644 index 00000000..5e202e61 --- /dev/null +++ b/tests/unit/DateTime/WeekdayTest.php @@ -0,0 +1,35 @@ +getPrevious()); + static::assertSame(Weekday::Tuesday, Weekday::Wednesday->getPrevious()); + static::assertSame(Weekday::Wednesday, Weekday::Thursday->getPrevious()); + static::assertSame(Weekday::Thursday, Weekday::Friday->getPrevious()); + static::assertSame(Weekday::Friday, Weekday::Saturday->getPrevious()); + static::assertSame(Weekday::Saturday, Weekday::Sunday->getPrevious()); + static::assertSame(Weekday::Sunday, Weekday::Monday->getPrevious()); + } + + public function testGetNext(): void + { + static::assertSame(Weekday::Tuesday, Weekday::Monday->getNext()); + static::assertSame(Weekday::Wednesday, Weekday::Tuesday->getNext()); + static::assertSame(Weekday::Thursday, Weekday::Wednesday->getNext()); + static::assertSame(Weekday::Friday, Weekday::Thursday->getNext()); + static::assertSame(Weekday::Saturday, Weekday::Friday->getNext()); + static::assertSame(Weekday::Sunday, Weekday::Saturday->getNext()); + static::assertSame(Weekday::Monday, Weekday::Sunday->getNext()); + } +} diff --git a/tests/unit/Filesystem/AbstractFilesystemTest.php b/tests/unit/Filesystem/AbstractFilesystemTest.php index 1feabac4..7436026d 100644 --- a/tests/unit/Filesystem/AbstractFilesystemTest.php +++ b/tests/unit/Filesystem/AbstractFilesystemTest.php @@ -20,8 +20,8 @@ abstract class AbstractFilesystemTest extends TestCase protected function setUp(): void { - if (OS\is_windows() || OS\is_darwin()) { - static::markTestSkipped('Filesystem tests are only executed on linux.'); + if (OS\is_windows()) { + static::markTestSkipped('Test can only be executed under *nix OS.'); } $this->cacheDirectory = Type\string()->assert(Filesystem\canonicalize(Str\join([ diff --git a/tests/unit/IO/PipeTest.php b/tests/unit/IO/PipeTest.php index 54b12984..a0c06ab8 100644 --- a/tests/unit/IO/PipeTest.php +++ b/tests/unit/IO/PipeTest.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\TestCase; use Psl; use Psl\Async; +use Psl\DateTime; use Psl\IO; final class PipeTest extends TestCase @@ -39,7 +40,7 @@ public function testReadWriteInParallel(): void $read_awaitable = Async\run(static function () use ($read, $spy): string { $spy->value .= '[read:sleep]'; - Async\sleep(0.003); + Async\sleep(DateTime\Duration::milliseconds(3)); $spy->value .= '[read:start]'; $content = $read->readAll(1000); $spy->value .= '[read:complete]'; @@ -50,7 +51,7 @@ public function testReadWriteInParallel(): void Async\run(static function () use ($write, $spy): void { $spy->value .= '[write:sleep]'; - Async\sleep(0.0035); + Async\sleep(DateTime\Duration::milliseconds(5)); $spy->value .= '[write:start]'; $write->writeAll('hello'); $spy->value .= '[write:complete]'; @@ -96,7 +97,7 @@ public function testReadAllTimedOut(): void $this->expectException(IO\Exception\TimeoutException::class); $this->expectExceptionMessage('Reached timeout while the handle is still not readable.'); - $read->readAll(timeout: 0.001); + $read->readAll(timeout: DateTime\Duration::milliseconds(1)); } public function testReadOnAlreadyClosedPipe(): void diff --git a/tests/unit/TCP/ServerTest.php b/tests/unit/TCP/ServerTest.php index 8d73774d..b62084bf 100644 --- a/tests/unit/TCP/ServerTest.php +++ b/tests/unit/TCP/ServerTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\TestCase; use Psl\Async; +use Psl\DateTime; use Psl\Network; use Psl\Network\Exception\AlreadyStoppedException; use Psl\TCP; @@ -78,7 +79,7 @@ public function testIncoming(): void { $server = TCP\Server::create('127.0.0.1'); $incoming = $server->incoming(); - Async\Scheduler::delay(0.01, static fn() => $server->close()); + Async\Scheduler::delay(DateTime\Duration::milliseconds(1), static fn() => $server->close()); Async\Scheduler::defer(static function () use ($server) { TCP\connect('127.0.0.1', $server->getLocalAddress()->port); });