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..e8ee0400
--- /dev/null
+++ b/docs/component/date-time.md
@@ -0,0 +1,63 @@
+
+
+[*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)
+- [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#L18)
+
+#### `Classes`
+
+- [DateTime](./../../src/Psl/DateTime/DateTime.php#L15)
+- [Duration](./../../src/Psl/DateTime/Duration.php#L31)
+- [Timestamp](./../../src/Psl/DateTime/Timestamp.php#L17)
+
+#### `Traits`
+
+- [DateTimeConvenienceMethodsTrait](./../../src/Psl/DateTime/DateTimeConvenienceMethodsTrait.php#L13)
+- [TemporalConvenienceMethodsTrait](./../../src/Psl/DateTime/TemporalConvenienceMethodsTrait.php#L14)
+
+#### `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..a97aad79 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(10));
}
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 99a7349d..7bd1e5ea 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,18 +57,18 @@ 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.
*
* @external-mutation-free
*/
- 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 60af0867..fbd715ee 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..ffd92199
--- /dev/null
+++ b/src/Psl/DateTime/DateTime.php
@@ -0,0 +1,484 @@
+
+ */
+ 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.
+ *
+ * @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 ||
+ $seconds < 0 || $seconds >= 60 ||
+ $minutes < 0 || $minutes >= 60 ||
+ $hours < 0 || $hours >= 24 ||
+ $month < 1 || $month > 12 ||
+ $day < 1 || $day > 31 || $day > Month::from($month)->getDaysForYear($year)
+ ) {
+ throw new Exception\InvalidArgumentException('One or more components of the date-time are out of valid ranges.');
+ }
+
+ $this->timestamp = $timestamp;
+ $this->timezone = $timezone;
+ $this->year = $year;
+ $this->month = $month;
+ $this->day = $day;
+ $this->hours = $hours;
+ $this->minutes = $minutes;
+ $this->seconds = $seconds;
+ $this->nanoseconds = $nanoseconds;
+ }
+
+ /**
+ * Creates a new {@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.
+ *
+ * @mutation-free
+ */
+ public static function now(?Timezone $timezone = null): DateTime
+ {
+ return self::fromTimestamp($timezone ?? Timezone::default(), Timestamp::now());
+ }
+
+ /**
+ * 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\InvalidArgumentException If any of the time components are outside their valid ranges,
+ * indicating an invalid date-time configuration.
+ *
+ * @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.
+ *
+ * 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\InvalidArgumentException If the combination of date-time components is invalid.
+ *
+ * @pure
+ */
+ 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);
+
+ // Validate the date-time components by comparing them to what was set
+ if (
+ !(
+ $calendar->get(IntlCalendar::FIELD_YEAR) === $year &&
+ ($calendar->get(IntlCalendar::FIELD_MONTH) + 1) === $month &&
+ $calendar->get(IntlCalendar::FIELD_DAY_OF_MONTH) === $day &&
+ $calendar->get(IntlCalendar::FIELD_HOUR_OF_DAY) === $hours &&
+ $calendar->get(IntlCalendar::FIELD_MINUTE) === $minutes &&
+ $calendar->get(IntlCalendar::FIELD_SECOND) === $seconds
+ )
+ ) {
+ throw new Exception\InvalidArgumentException(
+ 'The given components do not form a valid date-time.',
+ );
+ }
+
+ $timestampInSeconds = (int) ($calendar->getTime() / ((float) MILLISECONDS_PER_SECOND));
+ /** @psalm-suppress MissingThrowsDocblock */
+ $timestamp = Timestamp::fromRaw($timestampInSeconds, $nanoseconds);
+
+ 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.
+ *
+ * @pure
+ */
+ public static function fromTimestamp(Timezone $timezone, Timestamp $timestamp): static
+ {
+ /** @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 DateTime} based on specified styles, pattern, timezone, and locale.
+ *
+ * This method offers a flexible way to convert a formatted date and time string back into a {@see DateTime} object.
+ *
+ * Usage example:
+ *
+ * ```php
+ * use Psl\DateTime;
+ * use Psl\Locale;
+ *
+ * $raw_string = '2023-03-15 12:00:00';
+ * $parsed_datetime = DateTime\DateTime::parse(
+ * $raw_string,
+ * date_style: null, // Uses default date style
+ * time_style: null, // Uses default time style
+ * pattern: 'yyyy-MM-dd HH:mm:ss', // Custom pattern matching the input string
+ * timezone: DateTime\Timezone::Utc, // Parsing according to Utc timezone
+ * locale: Locale\Locale::English // Parsing using the English locale
+ * );
+ * ```
+ *
+ * @param string $raw_string The date and time string to parse.
+ * @param null|FormatDateStyle $date_style Specifies the style of the date portion for parsing. Defaults to {@see DateFormatStyle::default()}.
+ * @param null|FormatTimeStyle $time_style Specifies the style of the time portion for parsing. Defaults to {@see TimeFormatStyle::default()}.
+ * @param null|FormatPattern|string $pattern Specifies a custom format pattern to be used for parsing. A default pattern is used if null.
+ * @param null|Timezone $timezone Determines the timezone for which the date and time are parsed. Defaults to {@see Timezone::default()}.
+ * @param null|Locale $locale Specifies the locale to be used for parsing locale-specific date and time components. Defaults to {@see Locale::default()}.
+ *
+ * @throws Exception\RuntimeException If failed to parse the given raw string.
+ *
+ * @return static Returns an instance of {@see DateTime} representing the parsed date and time.
+ *
+ * @mutation-free
+ *
+ *@see https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax
+ */
+ public static function parse(string $raw_string, null|FormatDateStyle $date_style = null, null|FormatTimeStyle $time_style = null, null|FormatPattern|string $pattern = null, null|Timezone $timezone = null, null|Locale $locale = null): static
+ {
+ $timezone ??= Timezone::default();
+
+ return self::fromTimestamp($timezone, Timestamp::parse($raw_string, $date_style, $time_style, $pattern, $timezone, $locale));
+ }
+
+ /**
+ * Returns the timestamp representation of this date time object.
+ *
+ * @mutation-free
+ */
+ public function getTimestamp(): Timestamp
+ {
+ return $this->timestamp;
+ }
+
+ /**
+ * Retrieves the year as an integer, following ISO-8601 conventions for numbering.
+ *
+ * This method returns the year part of the date. For years in the Anno Domini (AD) era, the returned value matches
+ * the Gregorian calendar year directly (e.g., 1 for AD 1, 2021 for AD 2021, etc.). For years before AD 1, the method
+ * adheres to the ISO-8601 standard, which does not use a year zero: 1 BC is represented as 0, 2 BC as -1, 3 BC as -2,
+ * and so forth. This ISO-8601 numbering facilitates straightforward mathematical operations on years across the AD/BC
+ * divide but may require conversion for user-friendly display or when interfacing with systems that use the traditional
+ * AD/BC notation.
+ *
+ * @return int The year, formatted according to ISO-8601 standards, where 1 AD is 1, 1 BC is 0, 2 BC is -1, etc.
+ *
+ * @mutation-free
+ */
+ public function getYear(): int
+ {
+ return $this->year;
+ }
+
+ /**
+ * Returns the month.
+ *
+ * @return int<1, 12>
+ *
+ * @mutation-free
+ */
+ public function getMonth(): int
+ {
+ return $this->month;
+ }
+
+ /**
+ * Returns the day.
+ *
+ * @return int<1, 31>
+ *
+ * @mutation-free
+ */
+ public function getDay(): int
+ {
+ return $this->day;
+ }
+
+ /**
+ * Returns the hours.
+ *
+ * @return int<0, 23>
+ *
+ * @mutation-free
+ */
+ public function getHours(): int
+ {
+ return $this->hours;
+ }
+
+ /**
+ * Returns the minutes.
+ *
+ * @return int<0, 59>
+ *
+ * @mutation-free
+ */
+ public function getMinutes(): int
+ {
+ return $this->minutes;
+ }
+
+ /**
+ * Returns the seconds.
+ *
+ * @return int<0, 59>
+ *
+ * @mutation-free
+ */
+ public function getSeconds(): int
+ {
+ return $this->seconds;
+ }
+
+ /**
+ * Returns the nanoseconds.
+ *
+ * @return int<0, 999999999>
+ *
+ * @mutation-free
+ */
+ public function getNanoseconds(): int
+ {
+ return $this->nanoseconds;
+ }
+
+ /**
+ * Gets the timezone associated with the date and time.
+ *
+ * @mutation-free
+ */
+ public function getTimezone(): Timezone
+ {
+ return $this->timezone;
+ }
+
+ /**
+ * Returns a new instance with the specified date.
+ *
+ * @param Month|int<1, 12> $month
+ * @param int<1, 31> $day
+ *
+ * @throws Exception\InvalidArgumentException If specifying the date would result in an invalid date/time.
+ * This can happen if the combination of year, month, and day does not constitute a valid date (e.g., April 31st, February 29th in a non-leap year).
+ *
+ * @mutation-free
+ */
+ public function withDate(int $year, Month|int $month, int $day): static
+ {
+ return static::fromParts(
+ $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\InvalidArgumentException If specifying the time would result in an invalid time (e.g., hours greater than 23, minutes or seconds greater than 59).
+ *
+ * @mutation-free
+ */
+ public function withTime(int $hours, int $minutes, int $seconds = 0, int $nanoseconds = 0): static
+ {
+ return static::fromParts(
+ $this->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.
+ *
+ * @mutation-free
+ */
+ public function plus(Duration $duration): static
+ {
+ return static::fromTimestamp($this->timezone, $this->getTimestamp()->plus($duration));
+ }
+
+ /**
+ * 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.
+ *
+ * @mutation-free
+ */
+ public function minus(Duration $duration): static
+ {
+ return static::fromTimestamp($this->timezone, $this->getTimestamp()->minus($duration));
+ }
+
+ 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..13971901
--- /dev/null
+++ b/src/Psl/DateTime/DateTimeConvenienceMethodsTrait.php
@@ -0,0 +1,556 @@
+equals($other) && $this->getTimezone() === $other->getTimezone();
+ }
+
+ /**
+ * Returns a new instance with the specified year.
+ *
+ * @throws Exception\InvalidArgumentException If changing the year would result in an invalid date (e.g., setting a year that turns February 29th into a date in a non-leap year).
+ *
+ * @mutation-free
+ */
+ public function withYear(int $year): static
+ {
+ return $this->withDate($year, $this->getMonth(), $this->getDay());
+ }
+
+ /**
+ * Returns a new instance with the specified month.
+ *
+ * @param Month|int<1, 12> $month
+ *
+ * @throws Exception\InvalidArgumentException If changing the month would result in an invalid date/time.
+ *
+ * @mutation-free
+ */
+ public function withMonth(Month|int $month): static
+ {
+ return $this->withDate($this->getYear(), $month, $this->getDay());
+ }
+
+ /**
+ * Returns a new instance with the specified day.
+ *
+ * @param int<1, 31> $day
+ *
+ * @throws Exception\InvalidArgumentException If changing the day would result in an invalid date/time.
+ *
+ * @mutation-free
+ */
+ public function withDay(int $day): static
+ {
+ return $this->withDate($this->getYear(), $this->getMonth(), $day);
+ }
+
+ /**
+ * Returns a new instance with the specified hours.
+ *
+ * @param int<0, 23> $hours
+ *
+ * @throws Exception\InvalidArgumentException If specifying the hours would result in an invalid time (e.g., hours greater than 23).
+ *
+ * @mutation-free
+ */
+ public function withHours(int $hours): static
+ {
+ return $this->withTime($hours, $this->getMinutes(), $this->getSeconds(), $this->getNanoseconds());
+ }
+
+ /**
+ * Returns a new instance with the specified minutes.
+ *
+ * @param int<0, 59> $minutes
+ *
+ * @throws Exception\InvalidArgumentException If specifying the minutes would result in an invalid time (e.g., minutes greater than 59).
+ *
+ * @mutation-free
+ */
+ public function withMinutes(int $minutes): static
+ {
+ return $this->withTime($this->getHours(), $minutes, $this->getSeconds(), $this->getNanoseconds());
+ }
+
+ /**
+ * Returns a new instance with the specified seconds.
+ *
+ * @param int<0, 59> $seconds
+ *
+ * @throws Exception\InvalidArgumentException If specifying the seconds would result in an invalid time (e.g., seconds greater than 59).
+ *
+ * @mutation-free
+ */
+ public function withSeconds(int $seconds): static
+ {
+ return $this->withTime($this->getHours(), $this->getMinutes(), $seconds, $this->getNanoseconds());
+ }
+
+ /**
+ * Returns a new instance with the specified nanoseconds.
+ *
+ * @param int<0, 999999999> $nanoseconds
+ *
+ * @throws Exception\InvalidArgumentException If specifying the nanoseconds would result in an invalid time, considering that valid nanoseconds range from 0 to 999,999,999.
+ *
+ * @mutation-free
+ */
+ public function withNanoseconds(int $nanoseconds): static
+ {
+ return $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.
+ *
+ * @mutation-free
+ */
+ public function getDate(): array
+ {
+ return [$this->getYear(), $this->getMonth(), $this->getDay()];
+ }
+
+ /**
+ * Returns the time (hours, minutes, seconds, nanoseconds).
+ *
+ * @return array{
+ * int<0, 23>,
+ * int<0, 59>,
+ * int<0, 59>,
+ * int<0, 999999999>,
+ * }
+ *
+ * @mutation-free
+ */
+ public function getTime(): array
+ {
+ return [
+ $this->getHours(),
+ $this->getMinutes(),
+ $this->getSeconds(),
+ $this->getNanoseconds(),
+ ];
+ }
+
+ /**
+ * Returns the date and time parts (year, month, day, hours, minutes, seconds, nanoseconds).
+ *
+ * @return array{
+ * int,
+ * int<1, 12>,
+ * int<1, 31>,
+ * int<0, 23>,
+ * int<0, 59>,
+ * int<0, 59>,
+ * int<0, 999999999>,
+ * }
+ *
+ * @mutation-free
+ */
+ public function getParts(): array
+ {
+ return [
+ $this->getYear(),
+ $this->getMonth(),
+ $this->getDay(),
+ $this->getHours(),
+ $this->getMinutes(),
+ $this->getSeconds(),
+ $this->getNanoseconds(),
+ ];
+ }
+
+ /**
+ * Retrieves the era of the date represented by this DateTime instance.
+ *
+ * This method returns an instance of the `Era` enum, which indicates whether the date
+ * falls in the Anno Domini (AD) or Before Christ (BC) era. The era is determined based on the year
+ * of the date this object represents, with years designated as BC being negative
+ * and years in AD being positive.
+ *
+ * @mutation-free
+ */
+ public function getEra(): Era
+ {
+ return Era::fromYear($this->getYear());
+ }
+
+ /**
+ * Returns the century number for the year stored in this object.
+ *
+ * @mutation-free
+ */
+ public function getCentury(): int
+ {
+ return (int)($this->getYear() / 100) + 1;
+ }
+
+ /**
+ * Returns the short format of the year (last 2 digits).
+ *
+ * @return int<-99, 99> The short format of the year.
+ *
+ * @mutation-free
+ */
+ public function getYearShort(): int
+ {
+ return $this->getYear() % 100;
+ }
+
+ /**
+ * Returns the hours using the 12-hour format (1 to 12) along with the meridiem indicator.
+ *
+ * @return array{int<1, 12>, Meridiem} The hours and meridiem indicator.
+ *
+ * @mutation-free
+ */
+ public function getTwelveHours(): array
+ {
+ return [
+ ($this->getHours() % 12 ?: 12),
+ ($this->getHours() < 12 ? Meridiem::AnteMeridiem : Meridiem::PostMeridiem),
+ ];
+ }
+
+ /**
+ * Retrieves the ISO-8601 year and week number corresponding to the date.
+ *
+ * This method returns an array consisting of two integers: the first represents the year, and the second
+ * represents the week number according to ISO-8601 standards, which ranges from 1 to 53. The week numbering
+ * follows the ISO-8601 specification, where a week starts on a Monday and the first week of the year is the
+ * one that contains at least four days of the new year.
+ *
+ * Due to the ISO-8601 week numbering rules, the returned year might not always match the Gregorian year
+ * obtained from `$this->getYear()`. Specifically:
+ *
+ * - The first few days of January might belong to the last week of the previous year if they fall before
+ * the first Thursday of January.
+ *
+ * - Conversely, the last days of December might be part of the first week of the following year if they
+ * extend beyond the last Thursday of December.
+ *
+ * Examples:
+ * - For the date 2020-01-01, it returns [2020, 1], indicating the first week of 2020.
+ * - For the date 2021-01-01, it returns [2020, 53], showing that this day is part of the last week of 2020
+ * according to ISO-8601.
+ *
+ * @return array{int, int<1, 53>}
+ *
+ * @mutation-free
+ */
+ public function getISOWeekNumber(): array
+ {
+ $year = $this->getYear();
+ /** @var int<1, 53> $week */
+ $week = (int)$this->format(pattern: 'W'); // Correct format specifier for ISO-8601 week number is 'W', not '%V'
+
+ // Adjust the year based on ISO week numbering rules
+ if ($week === 1 && $this->getMonth() === 12) {
+ ++$year; // Belongs to the first week of the next year
+ } elseif ($week > 50 && $this->getMonth() === 1) {
+ --$year; // Belongs to the last week of the previous year
+ }
+
+ return [$year, $week];
+ }
+
+ /**
+ * Gets the weekday of the date.
+ *
+ * @return Weekday The weekday.
+ *
+ * @mutation-free
+ */
+ public function getWeekday(): Weekday
+ {
+ return Weekday::from(((int)$this->format(pattern: '%u')) + 1);
+ }
+
+ /**
+ * Checks if the date and time is in daylight saving time.
+ *
+ * @return bool True if in daylight saving time, false otherwise.
+ *
+ * @mutation-free
+ */
+ public function isDaylightSavingTime(): bool
+ {
+ return !$this->getTimezone()->getDaylightSavingTimeOffset($this)->isZero();
+ }
+
+ /**
+ * Returns whether the year stored in this DateTime object is a leap year
+ * (has 366 days including February 29).
+ */
+ public function isLeapYear(): bool
+ {
+ return namespace\is_leap_year($this->year);
+ }
+
+ /**
+ * Adds the specified years to this date-time object, returning a new instance with the added years.
+ *
+ * @throws Exception\UnderflowException If adding the years results in an arithmetic underflow.
+ * @throws Exception\OverflowException If adding the years results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function plusYears(int $years): static
+ {
+ if ($years < 0) {
+ return $this->minusYears(-$years);
+ }
+
+ $current_year = $this->getYear();
+
+ // Check for potential overflow when adding years
+ if ($current_year >= Math\INT64_MAX - $years || $current_year <= Math\INT64_MIN - $years) {
+ throw new Exception\OverflowException("Adding years results in a year that exceeds the representable integer range.");
+ }
+
+ $target_year = $current_year + $years;
+
+ // Handle the day and month for the target year, considering leap years
+ $current_month = $this->getMonth();
+ $current_day = $this->getDay();
+
+ $days_in_target_month = Month::from($current_month)->getDaysForYear($target_year);
+ // February 29 adjustment for non-leap target years
+ $target_day = Math\minva($days_in_target_month, $current_day);
+
+ /** @psalm-suppress MissingThrowsDocblock */
+ return $this->withDate($target_year, $current_month, $target_day);
+ }
+
+ /**
+ * Subtracts the specified years from this date-time object, returning a new instance with the subtracted years.
+ *
+ * @throws Exception\UnderflowException If subtracting the years results in an arithmetic underflow.
+ *
+ * @mutation-free
+ */
+ public function minusYears(int $years): static
+ {
+ if ($years < 0) {
+ /** @psalm-suppress MissingThrowsDocblock */
+ return $this->plusYears(-$years);
+ }
+
+ $current_year = $this->getYear();
+
+ // Check for potential underflow when subtracting years
+ if ($current_year <= Math\INT64_MIN + $years) {
+ throw new Exception\UnderflowException("Subtracting years results in a year that underflows the representable integer range.");
+ }
+
+ $target_year = $current_year - $years;
+
+ $current_month = $this->getMonth();
+ $current_day = $this->getDay();
+
+ $days_in_target_month = Month::from($current_month)->getDaysForYear($target_year);
+ $target_day = Math\minva($current_day, $days_in_target_month);
+
+ /** @psalm-suppress MissingThrowsDocblock */
+ return $this->withDate($target_year, $current_month, $target_day);
+ }
+
+ /**
+ * Adds the specified months to this date-time object, returning a new instance with the added months.
+ *
+ * @throws Exception\UnderflowException If adding the months results in an arithmetic underflow.
+ * @throws Exception\OverflowException If adding the months results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function plusMonths(int $months): static
+ {
+ if ($months < 0) {
+ return $this->minusMonths(-$months);
+ }
+
+ $current_day = $this->getDay();
+ $current_month = $this->getMonth();
+ $current_year = $this->getYear();
+
+ if ($current_year >= Math\INT64_MAX) {
+ throw new Exception\OverflowException("Cannot add months to the maximum year.");
+ }
+
+ // Calculate target month and year
+ $total_months = $current_month + $months;
+ /** @psalm-suppress MissingThrowsDocblock */
+ $target_year = $current_year + Math\div($total_months - 1, 12);
+ if ($target_year >= Math\INT64_MAX || $target_year <= Math\INT64_MIN) {
+ throw new Exception\OverflowException("Adding months results in a year that exceeds the representable integer range.");
+ }
+
+ $target_month = $total_months % 12;
+ if ($target_month === 0) {
+ $target_month = 12;
+ --$target_year; // Adjust if the modulo brings us back to December of the previous year
+ }
+
+ // Days adjustment for end-of-month scenarios
+ $days_in_target_month = Month::from($target_month)->getDaysForYear($target_year);
+
+ $target_day = Math\minva($current_day, $days_in_target_month);
+
+ /** @psalm-suppress MissingThrowsDocblock */
+ return $this->withDate($target_year, $target_month, $target_day);
+ }
+
+ /**
+ * Subtracts the specified months from this date-time object, returning a new instance with the subtracted months.
+ *
+ * @throws Exception\UnderflowException If subtracting the months results in an arithmetic underflow.
+ * @throws Exception\OverflowException If subtracting the months results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function minusMonths(int $months): static
+ {
+ if ($months < 0) {
+ return $this->plusMonths(-$months);
+ }
+
+ $current_day = $this->getDay();
+ $current_month = $this->getMonth();
+ $current_year = $this->getYear();
+
+ /**
+ * Calculate how many years to subtract based on the months.
+ *
+ * @psalm-suppress MissingThrowsDocblock
+ */
+ $years_to_subtract = Math\div($months, 12);
+ $months_to_subtract_after_years = $months % 12;
+
+ // Check for potential underflow when subtracting months
+ if ($current_year <= Math\INT64_MIN + $years_to_subtract) {
+ throw new Exception\UnderflowException("Subtracting months results in a year that underflows the representable integer range.");
+ }
+
+ $new_year = $current_year - $years_to_subtract;
+ $new_month = $current_month - $months_to_subtract_after_years;
+
+ // Adjust if new_month goes below 1 (January)
+ if ($new_month < 1) {
+ $new_month += 12; // Cycle back to December
+ --$new_year; // Adjust the year since we cycled back
+ }
+
+ // Ensure year adjustment didn't underflow
+ if ($new_year < Math\INT64_MIN) {
+ throw new Exception\UnderflowException("Subtracting months results in a year that underflows the representable integer range.");
+ }
+
+ $days_in_new_month = Month::from($new_month)->getDaysForYear($new_year);
+ $new_day = Math\minva($current_day, $days_in_new_month);
+
+ /** @psalm-suppress MissingThrowsDocblock */
+ return $this->withDate($new_year, $new_month, $new_day);
+ }
+
+ /**
+ * Adds the specified days to this date-time object, returning a new instance with the added days.
+ *
+ * @throws Exception\UnderflowException If adding the days results in an arithmetic underflow.
+ * @throws Exception\OverflowException If adding the days results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function plusDays(int $days): static
+ {
+ return $this->plusHours(HOURS_PER_DAY * $days);
+ }
+
+ /**
+ * Subtracts the specified days from this date-time object, returning a new instance with the subtracted days.
+ *
+ * @throws Exception\UnderflowException If subtracting the days results in an arithmetic underflow.
+ * @throws Exception\OverflowException If subtracting the days results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function minusDays(int $days): static
+ {
+ return $this->plusDays(-$days);
+ }
+
+
+ /**
+ * Formats this {@see DateTimeInterface} instance based on specified styles, pattern, timezone, and locale.
+ *
+ * This method provides a flexible way to format the date and time representation of this {@see DateTimeInterface} object,
+ * allowing for customization through various parameters. If parameters are not provided, default values are used,
+ * ensuring a sensible output even when minimal information is specified.
+ *
+ * Usage example:
+ *
+ * ```php
+ * use Psl\DateTime;
+ * use Psl\Locale;
+ * use Psl\IO;
+ *
+ * $date_time = DateTime\DateTime::now();
+ * $formatted = $date_time->format(locale: Locale\Locale::Arabic);
+ *
+ * IO\write_line($formatted); //
+ * ```
+ *
+ * @param null|FormatDateStyle $date_style Specifies the style of the date portion for parsing. Defaults to {@see DateFormatStyle::default()}.
+ * @param null|FormatTimeStyle $time_style Specifies the style of the time portion for parsing. Defaults to {@see TimeFormatStyle::default()}.
+ * @param null|FormatPattern|string $pattern Specifies a custom format pattern to be used for parsing. A default, implementation-specific pattern is used if null.
+ * @param null|Timezone $timezone Determines the timezone for which the date and time are formatted. Defaults to {@see static::getTimezone()}.
+ * @param null|Locale $locale Specifies the locale to be used for formatting locale-specific date and time components. Defaults to {@see Locale::default()}.
+ *
+ * @return string The formatted date and time string.
+ *
+ * @see https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax
+ *
+ * @mutation-free
+ */
+ public function format(null|FormatDateStyle $date_style = null, null|FormatTimeStyle $time_style = null, null|FormatPattern|string $pattern = null, null|Timezone $timezone = null, null|Locale $locale = null): string
+ {
+ return $this->getTimestamp()->format($date_style, $time_style, $pattern, $timezone ?? $this->getTimezone(), $locale);
+ }
+
+ /**
+ * Converts the date and time to the specified timezone.
+ *
+ * @param Timezone $timezone The timezone to convert to.
+ *
+ * @mutation-free
+ */
+ public function convertToTimezone(Timezone $timezone): static
+ {
+ return static::fromTimestamp($timezone, $this->getTimestamp());
+ }
+}
diff --git a/src/Psl/DateTime/DateTimeInterface.php b/src/Psl/DateTime/DateTimeInterface.php
new file mode 100644
index 00000000..e8ddfb15
--- /dev/null
+++ b/src/Psl/DateTime/DateTimeInterface.php
@@ -0,0 +1,442 @@
+ $month
+ * @param int<1, 31> $day
+ *
+ * @throws Exception\InvalidArgumentException If specifying the date would result in an invalid date/time.
+ * This can happen if the combination of year, month, and day does not constitute a valid date (e.g., April 31st, February 29th in a non-leap year).
+ *
+ * @mutation-free
+ */
+ public function withDate(int $year, Month|int $month, int $day): static;
+
+ /**
+ * Returns a new instance with the specified time.
+ *
+ * @param int<0, 23> $hours
+ * @param int<0, 59> $minutes
+ * @param int<0, 59> $seconds
+ * @param int<0, 999999999> $nanoseconds
+ *
+ * @throws Exception\InvalidArgumentException If specifying the time would result in an invalid time (e.g., hours greater than 23, minutes or seconds greater than 59).
+ *
+ * @mutation-free
+ */
+ public function withTime(int $hours, int $minutes, int $seconds = 0, int $nanoseconds = 0): static;
+
+ /**
+ * Returns a new instance with the specified year.
+ *
+ * @throws Exception\InvalidArgumentException If changing the year would result in an invalid date (e.g., setting a year that turns February 29th into a date in a non-leap year).
+ *
+ * @mutation-free
+ */
+ public function withYear(int $year): static;
+
+ /**
+ * Returns a new instance with the specified month.
+ *
+ * @param Month|int<1, 12> $month
+ *
+ * @throws Exception\InvalidArgumentException If changing the month would result in an invalid date/time.
+ *
+ * @mutation-free
+ */
+ public function withMonth(Month|int $month): static;
+
+ /**
+ * Returns a new instance with the specified day.
+ *
+ * @param int<1, 31> $day
+ *
+ * @throws Exception\InvalidArgumentException If changing the day would result in an invalid date/time.
+ *
+ * @mutation-free
+ */
+ public function withDay(int $day): static;
+
+ /**
+ * Returns a new instance with the specified hours.
+ *
+ * @param int<0, 23> $hours
+ *
+ * @throws Exception\InvalidArgumentException If specifying the hours would result in an invalid time (e.g., hours greater than 23).
+ *
+ * @mutation-free
+ */
+ public function withHours(int $hours): static;
+
+ /**
+ * Returns a new instance with the specified minutes.
+ *
+ * @param int<0, 59> $minutes
+ *
+ * @throws Exception\InvalidArgumentException If specifying the minutes would result in an invalid time (e.g., minutes greater than 59).
+ *
+ * @mutation-free
+ */
+ public function withMinutes(int $minutes): static;
+
+ /**
+ * Returns a new instance with the specified seconds.
+ *
+ * @param int<0, 59> $seconds
+ *
+ * @throws Exception\InvalidArgumentException If specifying the seconds would result in an invalid time (e.g., seconds greater than 59).
+ *
+ * @mutation-free
+ */
+ public function withSeconds(int $seconds): static;
+
+ /**
+ * Returns a new instance with the specified nanoseconds.
+ *
+ * @param int<0, 999999999> $nanoseconds
+ *
+ * @throws Exception\InvalidArgumentException If specifying the nanoseconds would result in an invalid time, considering that valid nanoseconds range from 0 to 999,999,999.
+ *
+ * @mutation-free
+ */
+ public function withNanoseconds(int $nanoseconds): static;
+
+ /**
+ * Returns the date (year, month, day).
+ *
+ * @return array{int, int<1, 12>, int<1, 31>} The date.
+ *
+ * @mutation-free
+ */
+ public function getDate(): array;
+
+ /**
+ * Returns the time (hours, minutes, seconds, nanoseconds).
+ *
+ * @return array{
+ * int<0, 23>,
+ * int<0, 59>,
+ * int<0, 59>,
+ * int<0, 999999999>,
+ * }
+ *
+ * @mutation-free
+ */
+ public function getTime(): array;
+
+ /**
+ * Returns the date and time parts (year, month, day, hours, minutes, seconds, nanoseconds).
+ *
+ * @return array{
+ * int,
+ * int<1, 12>,
+ * int<1, 31>,
+ * int<0, 23>,
+ * int<0, 59>,
+ * int<0, 59>,
+ * int<0, 999999999>,
+ * }
+ *
+ * @mutation-free
+ */
+ public function getParts(): array;
+
+ /**
+ * Retrieves the era of the date represented by this DateTime instance.
+ *
+ * This method returns an instance of the `Era` enum, which indicates whether the date
+ * falls in the Anno Domini (AD) or Before Christ (BC) era. The era is determined based on the year
+ * of the date this object represents, with years designated as BC being negative
+ * and years in AD being positive.
+ *
+ * @mutation-free
+ */
+ public function getEra(): Era;
+
+ /**
+ * Returns the century number for the year stored in this object.
+ *
+ * @mutation-free
+ */
+ public function getCentury(): int;
+
+ /**
+ * Retrieves the year as an integer, following ISO-8601 conventions for numbering.
+ *
+ * This method returns the year part of the date. For years in the Anno Domini (AD) era, the returned value matches
+ * the Gregorian calendar year directly (e.g., 1 for AD 1, 2021 for AD 2021, etc.). For years before AD 1, the method
+ * adheres to the ISO-8601 standard, which does not use a year zero: 1 BC is represented as 0, 2 BC as -1, 3 BC as -2,
+ * and so forth. This ISO-8601 numbering facilitates straightforward mathematical operations on years across the AD/BC
+ * divide but may require conversion for user-friendly display or when interfacing with systems that use the traditional
+ * AD/BC notation.
+ *
+ * @return int The year, formatted according to ISO-8601 standards, where 1 AD is 1, 1 BC is 0, 2 BC is -1, etc.
+ *
+ * @mutation-free
+ */
+ public function getYear(): int;
+
+ /**
+ * Returns the short format of the year (last 2 digits).
+ *
+ * @return int<-99, 99> The short format of the year.
+ *
+ * @mutation-free
+ */
+ public function getYearShort(): int;
+
+ /**
+ * Returns the month.
+ *
+ * @return int<1, 12>
+ *
+ * @mutation-free
+ */
+ public function getMonth(): int;
+
+ /**
+ * Returns the day.
+ *
+ * @return int<0, 31>
+ *
+ * @mutation-free
+ */
+ public function getDay(): int;
+
+ /**
+ * Returns the hours.
+ *
+ * @return int<0, 23>
+ *
+ * @mutation-free
+ */
+ public function getHours(): int;
+
+ /**
+ * Returns the hours using the 12-hour format (1 to 12) along with the meridiem indicator.
+ *
+ * @return array{int<1, 12>, Meridiem} The hours and meridiem indicator.
+ *
+ * @mutation-free
+ */
+ public function getTwelveHours(): array;
+
+ /**
+ * Returns the minutes.
+ *
+ * @return int<0, 59>
+ *
+ * @mutation-free
+ */
+ public function getMinutes(): int;
+
+ /**
+ * Returns the seconds.
+ *
+ * @return int<0, 59>
+ *
+ * @mutation-free
+ */
+ public function getSeconds(): int;
+
+ /**
+ * Returns the nanoseconds.
+ *
+ * @return int<0, 999999999>
+ *
+ * @mutation-free
+ */
+ public function getNanoseconds(): int;
+
+ /**
+ * Retrieves the ISO-8601 year and week number corresponding to the date.
+ *
+ * This method returns an array consisting of two integers: the first represents the year, and the second
+ * represents the week number according to ISO-8601 standards, which ranges from 1 to 53. The week numbering
+ * follows the ISO-8601 specification, where a week starts on a Monday and the first week of the year is the
+ * one that contains at least four days of the new year.
+ *
+ * Due to the ISO-8601 week numbering rules, the returned year might not always match the Gregorian year
+ * obtained from `$this->getYear()`. Specifically:
+ *
+ * - The first few days of January might belong to the last week of the previous year if they fall before
+ * the first Thursday of January.
+ *
+ * - Conversely, the last days of December might be part of the first week of the following year if they
+ * extend beyond the last Thursday of December.
+ *
+ * Examples:
+ * - For the date 2020-01-01, it returns [2020, 1], indicating the first week of 2020.
+ * - For the date 2021-01-01, it returns [2020, 53], showing that this day is part of the last week of 2020
+ * according to ISO-8601.
+ *
+ * @return array{int, int<1, 53>}
+ *
+ * @mutation-free
+ */
+ public function getISOWeekNumber(): array;
+
+ /**
+ * Gets the timezone associated with the date and time.
+ *
+ * @mutation-free
+ */
+ public function getTimezone(): Timezone;
+
+ /**
+ * Gets the weekday of the date.
+ *
+ * @return Weekday The weekday.
+ *
+ * @mutation-free
+ */
+ public function getWeekday(): Weekday;
+
+ /**
+ * Checks if the date and time is in daylight saving time.
+ *
+ * @return bool True if in daylight saving time, false otherwise.
+ *
+ * @mutation-free
+ */
+ public function isDaylightSavingTime(): bool;
+
+ /**
+ * Checks if the year is a leap year.
+ *
+ * @mutation-free
+ */
+ public function isLeapYear(): bool;
+
+ /**
+ * Adds the specified years to this date-time object, returning a new instance with the added years.
+ *
+ * @throws Exception\UnderflowException If adding the years results in an arithmetic underflow.
+ * @throws Exception\OverflowException If adding the years results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function plusYears(int $years): static;
+
+ /**
+ * Adds the specified months to this date-time object, returning a new instance with the added months.
+ *
+ * @throws Exception\UnderflowException If adding the months results in an arithmetic underflow.
+ * @throws Exception\OverflowException If adding the months results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function plusMonths(int $months): static;
+
+ /**
+ * Adds the specified days to this date-time object, returning a new instance with the added days.
+ *
+ * @throws Exception\UnderflowException If adding the days results in an arithmetic underflow.
+ * @throws Exception\OverflowException If adding the days results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function plusDays(int $days): static;
+
+ /**
+ * Subtracts the specified years from this date-time object, returning a new instance with the subtracted years.
+ *
+ * @throws Exception\UnderflowException If subtracting the years results in an arithmetic underflow.
+ * @throws Exception\OverflowException If subtracting the years results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function minusYears(int $years): static;
+
+ /**
+ * Subtracts the specified months from this date-time object, returning a new instance with the subtracted months.
+ *
+ * @throws Exception\UnderflowException If subtracting the months results in an arithmetic underflow.
+ * @throws Exception\OverflowException If subtracting the months results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function minusMonths(int $months): static;
+
+ /**
+ * Subtracts the specified days from this date-time object, returning a new instance with the subtracted days.
+ *
+ * @throws Exception\UnderflowException If subtracting the days results in an arithmetic underflow.
+ * @throws Exception\OverflowException If subtracting the days results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function minusDays(int $days): static;
+
+ /**
+ * Formats this {@see DateTimeInterface} instance based on specified styles, pattern, timezone, and locale.
+ *
+ * This method provides a flexible way to format the date and time representation of this {@see DateTimeInterface} object,
+ * allowing for customization through various parameters. If parameters are not provided, default values are used,
+ * ensuring a sensible output even when minimal information is specified.
+ *
+ * Usage example:
+ *
+ * ```php
+ * use Psl\DateTime;
+ * use Psl\Locale;
+ * use Psl\IO;
+ *
+ * $date_time = DateTime\DateTime::now();
+ * $formatted = $date_time->format(locale: Locale\Locale::Arabic);
+ *
+ * IO\write_line($formatted); //
+ * ```
+ *
+ * @param null|FormatDateStyle $date_style Specifies the style of the date portion for parsing. Defaults to {@see DateFormatStyle::default()}.
+ * @param null|FormatTimeStyle $time_style Specifies the style of the time portion for parsing. Defaults to {@see TimeFormatStyle::default()}.
+ * @param null|FormatPattern|string $pattern Specifies a custom format pattern to be used for parsing. A default, implementation-specific pattern is used if null.
+ * @param null|Timezone $timezone Determines the timezone for which the date and time are formatted. Defaults to {@see static::getTimezone()}.
+ * @param null|Locale $locale Specifies the locale to be used for formatting locale-specific date and time components. Defaults to {@see Locale::default()}.
+ *
+ * @return string The formatted date and time string.
+ *
+ * @see https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax
+ *
+ * @mutation-free
+ */
+ public function format(null|FormatDateStyle $date_style = null, null|FormatTimeStyle $time_style = null, null|FormatPattern|string $pattern = null, null|Timezone $timezone = null, null|Locale $locale = null): string;
+
+ /**
+ * Converts the date and time to the specified timezone.
+ *
+ * @param Timezone $timezone The timezone to convert to.
+ *
+ * @mutation-free
+ */
+ public function convertToTimezone(Timezone $timezone): static;
+}
diff --git a/src/Psl/DateTime/Duration.php b/src/Psl/DateTime/Duration.php
new file mode 100644
index 00000000..f8a9e775
--- /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}
+ *
+ * @mutation-free
+ */
+ public function getParts(): array
+ {
+ return [$this->hours, $this->minutes, $this->seconds, $this->nanoseconds];
+ }
+
+ /**
+ * Returns the "hours" part of this time duration.
+ *
+ * @mutation-free
+ */
+ public function getHours(): int
+ {
+ return $this->hours;
+ }
+
+ /**
+ * Returns the "minutes" part of this time duration.
+ *
+ * @mutation-free
+ */
+ public function getMinutes(): int
+ {
+ return $this->minutes;
+ }
+
+ /**
+ * Returns the "seconds" part of this time duration.
+ *
+ * @mutation-free
+ */
+ public function getSeconds(): int
+ {
+ return $this->seconds;
+ }
+
+ /**
+ * Returns the "nanoseconds" part of this time duration.
+ *
+ * @mutation-free
+ */
+ public function getNanoseconds(): int
+ {
+ return $this->nanoseconds;
+ }
+
+ /**
+ * Computes, and returns the total duration of the instance in hours as a floating-point number,
+ * including any fractional parts.
+ *
+ * @mutation-free
+ */
+ public function getTotalHours(): float
+ {
+ /** @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.
+ *
+ * @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.
+ *
+ * @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.
+ *
+ * @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.
+ *
+ * @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.
+ *
+ * @mutation-free
+ */
+ public function isZero(): bool
+ {
+ return $this->hours === 0 &&
+ $this->minutes === 0 &&
+ $this->seconds === 0 &&
+ $this->nanoseconds === 0;
+ }
+
+ /**
+ * Checks if the duration is positive, implying that all non-zero components are positive.
+ *
+ * Due to normalization, it is guaranteed that a positive time duration will
+ * have all of its parts (hours, minutes, seconds, nanoseconds) positive or
+ * equal to 0.
+ *
+ * Note that this method returns false if all parts are equal to 0.
+ *
+ * @mutation-free
+ */
+ public function isPositive(): bool
+ {
+ return $this->hours > 0 ||
+ $this->minutes > 0 ||
+ $this->seconds > 0 ||
+ $this->nanoseconds > 0;
+ }
+
+ /**
+ * Checks if the duration is negative, implying that all non-zero components are negative.
+ *
+ * Due to normalization, it is guaranteed that a negative time duration will
+ * have all of its parts (hours, minutes, seconds, nanoseconds) negative or
+ * equal to 0.
+ *
+ * Note that this method returns false if all parts are equal to 0.
+ *
+ * @mutation-free
+ */
+ public function isNegative(): bool
+ {
+ return $this->hours < 0 ||
+ $this->minutes < 0 ||
+ $this->seconds < 0 ||
+ $this->nanoseconds < 0;
+ }
+
+ /**
+ * Returns a new instance with the "hours" part changed to the specified
+ * value.
+ *
+ * Note that due to normalization, the actual value in the returned
+ * instance may differ, and this may affect other parts of the returned
+ * instance too.
+ *
+ * For example, `Duration::hours(2, 30)->withHours(-1)` is equivalent to
+ * `Duration::hours(-1, 30)` which normalizes to "-30 minute(s)".
+ *
+ * @mutation-free
+ */
+ public function withHours(int $hours): self
+ {
+ return self::fromParts(
+ $hours,
+ $this->minutes,
+ $this->seconds,
+ $this->nanoseconds,
+ );
+ }
+
+ /**
+ * Returns a new instance with the "minutes" part changed to the specified
+ * value.
+ *
+ * Note that due to normalization, the actual value in the returned
+ * instance may differ, and this may affect other parts of the returned
+ * instance too.
+ *
+ * For example, `Duration::minutes(2, 30)->withMinutes(-1)` is equivalent to
+ * `Duration::minutes(-1, 30)` which normalizes to "-30 second(s)".
+ *
+ * @mutation-free
+ */
+ public function withMinutes(int $minutes): self
+ {
+ return self::fromParts(
+ $this->hours,
+ $minutes,
+ $this->seconds,
+ $this->nanoseconds,
+ );
+ }
+
+ /**
+ * Returns a new instance with the "seconds" part changed to the specified
+ * value.
+ *
+ * Note that due to normalization, the actual value in the returned
+ * instance may differ, and this may affect other parts of the returned
+ * instance too.
+ *
+ * For example, `Duration::minutes(2, 30)->withSeconds(-30)` is equivalent
+ * to `Duration::minutes(2, -30)` which normalizes to "1 minute(s), 30 second(s)".
+ *
+ * @mutation-free
+ */
+ public function withSeconds(int $seconds): self
+ {
+ return self::fromParts(
+ $this->hours,
+ $this->minutes,
+ $seconds,
+ $this->nanoseconds,
+ );
+ }
+
+ /**
+ * Returns a new instance with the "nanoseconds" part changed to the specified
+ * value.
+ *
+ * Note that due to normalization, the actual value in the returned
+ * instance may differ, and this may affect other parts of the returned
+ * instance too.
+ *
+ * For example, `Duration::seconds(2)->withNanoseconds(-1)` is equivalent
+ * to `Duration::seconds(2, -1)` which normalizes to "1 second(s), 999999999 nanosecond(s)".
+ *
+ * @mutation-free
+ */
+ public function withNanoseconds(int $nanoseconds): self
+ {
+ return self::fromParts(
+ $this->hours,
+ $this->minutes,
+ $this->seconds,
+ $nanoseconds,
+ );
+ }
+
+ /**
+ * Implements a comparison between this duration and another, based on their duration.
+ *
+ * @param Duration $other
+ *
+ * @mutation-free
+ */
+ public function compare(mixed $other): Comparison\Order
+ {
+ if ($this->hours !== $other->hours) {
+ return Comparison\Order::from($this->hours <=> $other->hours);
+ }
+
+ if ($this->minutes !== $other->minutes) {
+ return Comparison\Order::from($this->minutes <=> $other->minutes);
+ }
+
+ if ($this->seconds !== $other->seconds) {
+ return Comparison\Order::from($this->seconds <=> $other->seconds);
+ }
+
+ return Comparison\Order::from($this->nanoseconds <=> $other->nanoseconds);
+ }
+
+ /**
+ * Evaluates whether this duration is equivalent to another, considering all time components.
+ *
+ * @param Duration $other
+ *
+ * @mutation-free
+ */
+ public function equals(mixed $other): bool
+ {
+ return $this->compare($other) === Comparison\Order::Equal;
+ }
+
+ /**
+ * Determines if this duration is shorter than another.
+ *
+ * @mutation-free
+ */
+ public function shorter(self $other): bool
+ {
+ return $this->compare($other) === Comparison\Order::Less;
+ }
+
+ /**
+ * Determines if this duration is shorter than, or equivalent to another.
+ *
+ * @mutation-free
+ */
+ public function shorterOrEqual(self $other): bool
+ {
+ return $this->compare($other) !== Comparison\Order::Greater;
+ }
+
+ /**
+ * Determines if this duration is longer than another.
+ *
+ * @mutation-free
+ */
+ public function longer(self $other): bool
+ {
+ return $this->compare($other) === Comparison\Order::Greater;
+ }
+
+ /**
+ * Determines if this duration is longer than, or equivalent to another.
+ *
+ * @mutation-free
+ */
+ public function longerOrEqual(self $other): bool
+ {
+ return $this->compare($other) !== Comparison\Order::Less;
+ }
+
+ /**
+ * Returns true if this instance represents a time duration longer than $a but
+ * shorter than $b, or vice-versa (shorter than $a but longer than $b), or if
+ * this instance is equal to $a and/or $b. Returns false if this instance is
+ * shorter/longer than both.
+ *
+ * @mutation-free
+ */
+ public function betweenInclusive(self $a, self $b): bool
+ {
+ $ca = $this->compare($a);
+ $cb = $this->compare($b);
+
+ return $ca === Comparison\Order::Equal || $ca !== $cb;
+ }
+
+ /**
+ * Returns true if this instance represents a time duration longer than $a but
+ * shorter than $b, or vice-versa (shorter than $a but longer than $b).
+ * Returns false if this instance is equal to $a and/or $b, or shorter/longer
+ * than both.
+ *
+ * @mutation-free
+ */
+ public function betweenExclusive(self $a, self $b): bool
+ {
+ $ca = $this->compare($a);
+ $cb = $this->compare($b);
+
+ return $ca !== Comparison\Order::Equal && $cb !== Comparison\Order::Equal && $ca !== $cb;
+ }
+
+ /**
+ * Returns a new instance, converting a positive/negative duration to the
+ * opposite (negative/positive) duration of equal length. The resulting
+ * instance has all parts equivalent to the current instance's parts
+ * multiplied by -1.
+ *
+ * @mutation-free
+ */
+ public function invert(): self
+ {
+ if ($this->isZero()) {
+ return $this;
+ }
+
+ return new self(
+ -$this->hours,
+ -$this->minutes,
+ -$this->seconds,
+ -$this->nanoseconds,
+ );
+ }
+
+ /**
+ * Returns a new instance representing the sum of this instance and the
+ * provided `$other` instance. Note that time duration can be negative, so
+ * the resulting instance is not guaranteed to be shorter/longer than either
+ * of the inputs.
+ *
+ * This operation is commutative: `$a->plus($b) === $b->plus($a)`
+ *
+ * @mutation-free
+ */
+ public function plus(self $other): self
+ {
+ if ($other->isZero()) {
+ return $this;
+ }
+
+ if ($this->isZero()) {
+ return $other;
+ }
+
+ return self::fromParts(
+ $this->hours + $other->hours,
+ $this->minutes + $other->minutes,
+ $this->seconds + $other->seconds,
+ $this->nanoseconds + $other->nanoseconds,
+ );
+ }
+
+ /**
+ * Returns a new instance representing the difference between this instance
+ * and the provided `$other` instance (i.e. `$other` subtracted from `$this`).
+ * Note that time duration can be negative, so the resulting instance is not
+ * guaranteed to be shorter/longer than either of the inputs.
+ *
+ * This operation is not commutative: `$a->minus($b) !== $b->minus($a)`
+ * But: `$a->minus($b) === $b->minus($a)->invert()`
+ *
+ * @mutation-free
+ */
+ public function minus(self $other): self
+ {
+ if ($other->isZero()) {
+ return $this;
+ }
+
+ if ($this->isZero()) {
+ return $other;
+ }
+
+ return self::fromParts(
+ $this->hours - $other->hours,
+ $this->minutes - $other->minutes,
+ $this->seconds - $other->seconds,
+ $this->nanoseconds - $other->nanoseconds,
+ );
+ }
+
+ /**
+ * Returns the time duration as string, useful e.g. for debugging. This is not
+ * meant to be a comprehensive way to format time durations for user-facing
+ * output.
+ *
+ * @param int<0, max> $max_decimals
+ *
+ * @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 = Iter\count($values);
+ while ($end > 0 && $values[$end - 1][0] === '0') {
+ --$end;
+ }
+
+ $start = 0;
+ while ($start < $end && $values[$start][0] === '0') {
+ ++$start;
+ }
+
+ $output = [];
+ for ($i = $start; $i < $end; ++$i) {
+ $output[] = $values[$i][0] . ' ' . $values[$i][1];
+ }
+
+ return Iter\is_empty($output) ? '0 second(s)' : Str\join($output, ', ');
+ }
+
+ /**
+ * Returns a string representation of the time duration.
+ *
+ * @mutation-free
+ */
+ public function __toString(): string
+ {
+ return $this->toString();
+ }
+
+ /**
+ * Returns data which can be serialized by json_encode().
+ *
+ * @return array{hours: int, minutes: int, seconds: int, nanoseconds: int}
+ *
+ * @mutation-free
+ */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'hours' => $this->hours,
+ 'minutes' => $this->minutes,
+ 'seconds' => $this->seconds,
+ 'nanoseconds' => $this->nanoseconds,
+ ];
+ }
+}
diff --git a/src/Psl/DateTime/Era.php b/src/Psl/DateTime/Era.php
new file mode 100644
index 00000000..244221e1
--- /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.
+ *
+ * @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 @@
+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..fc72d2a3
--- /dev/null
+++ b/src/Psl/DateTime/Internal/default_timezone.php
@@ -0,0 +1,27 @@
+= 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/system_time.php b/src/Psl/DateTime/Internal/system_time.php
new file mode 100644
index 00000000..c1ef0a09
--- /dev/null
+++ b/src/Psl/DateTime/Internal/system_time.php
@@ -0,0 +1,29 @@
+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..d32da248
--- /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.
+ *
+ * @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..d593ee76
--- /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.
+ *
+ * @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.
+ *
+ * @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.
+ *
+ * @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.
+ *
+ * @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..697c1259
--- /dev/null
+++ b/src/Psl/DateTime/TemporalConvenienceMethodsTrait.php
@@ -0,0 +1,253 @@
+getTimestamp()->toRaw();
+ $b = $other->getTimestamp()->toRaw();
+
+ return Comparison\Order::from($a[0] !== $b[0] ? $a[0] <=> $b[0] : $a[1] <=> $b[1]);
+ }
+
+ /**
+ * Checks if this temporal object represents the same time as the given one.
+ *
+ * @param TemporalInterface $other
+ *
+ * @mutation-free
+ */
+ public function equals(mixed $other): bool
+ {
+ return $this->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()}.
+ *
+ * @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.
+ *
+ * @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.
+ *
+ * @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.
+ *
+ * @mutation-free
+ */
+ public function after(TemporalInterface $other): bool
+ {
+ return $this->compare($other) === Comparison\Order::Greater;
+ }
+
+ /**
+ * Checks if this temporal object is between the given times (inclusive).
+ *
+ * @mutation-free
+ */
+ public function afterOrAtTheSameTime(TemporalInterface $other): bool
+ {
+ return $this->compare($other) !== Comparison\Order::Less;
+ }
+
+ /**
+ * Checks if this temporal object is between the given times (exclusive).
+ *
+ * @mutation-free
+ */
+ public function betweenTimeInclusive(TemporalInterface $a, TemporalInterface $b): bool
+ {
+ $ca = $this->compare($a);
+ $cb = $this->compare($b);
+
+ return $ca === Comparison\Order::Equal || $ca !== $cb;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @mutation-free
+ */
+ public function 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.
+ *
+ * @mutation-free
+ */
+ public function plusHours(int $hours): static
+ {
+ return $this->plus(Duration::hours($hours));
+ }
+
+ /**
+ * Adds the specified minutes to this temporal object, returning a new instance with the added minutes.
+ *
+ * @throws Exception\UnderflowException If adding the minutes results in an arithmetic underflow.
+ * @throws Exception\OverflowException If adding the minutes results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function plusMinutes(int $minutes): static
+ {
+ return $this->plus(Duration::minutes($minutes));
+ }
+
+ /**
+ * Adds the specified seconds to this temporal object, returning a new instance with the added seconds.
+ *
+ * @throws Exception\UnderflowException If adding the seconds results in an arithmetic underflow.
+ * @throws Exception\OverflowException If adding the seconds results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function plusSeconds(int $seconds): static
+ {
+ return $this->plus(Duration::seconds($seconds));
+ }
+
+ /**
+ * Adds the specified nanoseconds to this temporal object, returning a new instance with the added nanoseconds.
+ *
+ * @throws Exception\UnderflowException If adding the nanoseconds results in an arithmetic underflow.
+ * @throws Exception\OverflowException If adding the nanoseconds results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function plusNanoseconds(int $nanoseconds): static
+ {
+ return $this->plus(Duration::nanoseconds($nanoseconds));
+ }
+
+ /**
+ * Subtracts the specified hours from this temporal object, returning a new instance with the subtracted hours.
+ *
+ * @throws Exception\UnderflowException If subtracting the hours results in an arithmetic underflow.
+ * @throws Exception\OverflowException If subtracting the hours results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function minusHours(int $hours): static
+ {
+ return $this->minus(Duration::hours($hours));
+ }
+
+ /**
+ * Subtracts the specified minutes from this temporal object, returning a new instance with the subtracted minutes.
+ *
+ * @throws Exception\UnderflowException If subtracting the minutes results in an arithmetic underflow.
+ * @throws Exception\OverflowException If subtracting the minutes results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function minusMinutes(int $minutes): static
+ {
+ return $this->minus(Duration::minutes($minutes));
+ }
+
+ /**
+ * Subtracts the specified seconds from this temporal object, returning a new instance with the subtracted seconds.
+ *
+ * @throws Exception\UnderflowException If subtracting the seconds results in an arithmetic underflow.
+ * @throws Exception\OverflowException If subtracting the seconds results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function minusSeconds(int $seconds): static
+ {
+ return $this->minus(Duration::seconds($seconds));
+ }
+
+ /**
+ * Subtracts the specified nanoseconds from this temporal object, returning a new instance with the subtracted nanoseconds.
+ *
+ * @throws Exception\UnderflowException If subtracting the nanoseconds results in an arithmetic underflow.
+ * @throws Exception\OverflowException If subtracting the nanoseconds results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function minusNanoseconds(int $nanoseconds): static
+ {
+ return $this->minus(Duration::nanoseconds($nanoseconds));
+ }
+
+ /**
+ * Calculates the duration between this temporal object and the given one.
+ *
+ * @param TemporalInterface $other The temporal object to calculate the duration to.
+ *
+ * @return Duration The duration between the two temporal objects.
+ *
+ * @mutation-free
+ */
+ public function since(TemporalInterface $other): Duration
+ {
+ $a = $this->getTimestamp()->toRaw();
+ $b = $other->getTimestamp()->toRaw();
+
+ return Duration::fromParts(0, 0, $a[0] - $b[0], $a[1] - $b[1]);
+ }
+
+ /**
+ * Converts the current temporal object to a new {@see DateTimeInterface} instance in a different timezone.
+ *
+ * @param Timezone $timezone The target timezone for the conversion.
+ *
+ * @mutation-free
+ */
+ public function convertToTimezone(Timezone $timezone): DateTimeInterface
+ {
+ return DateTime::fromTimestamp($timezone, $this->getTimestamp());
+ }
+}
diff --git a/src/Psl/DateTime/TemporalInterface.php b/src/Psl/DateTime/TemporalInterface.php
new file mode 100644
index 00000000..9d83ae4b
--- /dev/null
+++ b/src/Psl/DateTime/TemporalInterface.php
@@ -0,0 +1,240 @@
+
+ * @extends Equable
+ */
+interface TemporalInterface extends Comparable, Equable, JsonSerializable
+{
+ /**
+ * Returns the timestamp representation of this temporal object.
+ *
+ * @mutation-free
+ */
+ public function getTimestamp(): Timestamp;
+
+ /**
+ * Checks if this temporal object represents the same time as the given one.
+ *
+ * Note: this method is an alias for {@see TemporalInterface::atTheSameTime()}.
+ *
+ * @param TemporalInterface $other
+ *
+ * @mutation-free
+ */
+ public function equals(mixed $other): bool;
+
+ /**
+ * Checks if this temporal object represents the same time as the given one.
+ *
+ * @mutation-free
+ */
+ public function atTheSameTime(TemporalInterface $other): bool;
+
+ /**
+ * Checks if this temporal object is before the given one.
+ *
+ * @mutation-free
+ */
+ public function before(TemporalInterface $other): bool;
+
+ /**
+ * Checks if this temporal object is before or at the same time as the given one.
+ *
+ * @mutation-free
+ */
+ public function beforeOrAtTheSameTime(TemporalInterface $other): bool;
+
+ /**
+ * Checks if this temporal object is after the given one.
+ *
+ * @mutation-free
+ */
+ public function after(TemporalInterface $other): bool;
+
+ /**
+ * Checks if this temporal object is after or at the same time as the given one.
+ *
+ * @mutation-free
+ */
+ public function afterOrAtTheSameTime(TemporalInterface $other): bool;
+
+ /**
+ * Checks if this temporal object is between the given times (inclusive).
+ *
+ * @mutation-free
+ */
+ public function betweenTimeInclusive(TemporalInterface $a, TemporalInterface $b): bool;
+
+ /**
+ * Checks if this temporal object is between the given times (exclusive).
+ *
+ * @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.
+ *
+ * @mutation-free
+ */
+ public function plus(Duration $duration): static;
+
+ /**
+ * Subtracts the specified duration from this temporal object, returning a new instance with the subtracted duration.
+ *
+ * @throws Exception\UnderflowException If subtracting the duration results in an arithmetic underflow.
+ * @throws Exception\OverflowException If subtracting the duration results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function minus(Duration $duration): static;
+
+ /**
+ * Adds the specified hours to this temporal object, returning a new instance with the added hours.
+ *
+ * @throws Exception\UnderflowException If adding the hours results in an arithmetic underflow.
+ * @throws Exception\OverflowException If adding the hours results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function plusHours(int $hours): static;
+
+ /**
+ * Adds the specified minutes to this temporal object, returning a new instance with the added minutes.
+ *
+ * @throws Exception\UnderflowException If adding the minutes results in an arithmetic underflow.
+ * @throws Exception\OverflowException If adding the minutes results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function plusMinutes(int $minutes): static;
+
+ /**
+ * Adds the specified seconds to this temporal object, returning a new instance with the added seconds.
+ *
+ * @throws Exception\UnderflowException If adding the seconds results in an arithmetic underflow.
+ * @throws Exception\OverflowException If adding the seconds results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function plusSeconds(int $seconds): static;
+
+ /**
+ * Adds the specified nanoseconds to this temporal object, returning a new instance with the added nanoseconds.
+ *
+ * @throws Exception\UnderflowException If adding the nanoseconds results in an arithmetic underflow.
+ * @throws Exception\OverflowException If adding the nanoseconds results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function plusNanoseconds(int $nanoseconds): static;
+
+ /**
+ * Subtracts the specified hours from this temporal object, returning a new instance with the subtracted hours.
+ *
+ * @throws Exception\UnderflowException If subtracting the hours results in an arithmetic underflow.
+ * @throws Exception\OverflowException If subtracting the hours results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function minusHours(int $hours): static;
+
+ /**
+ * Subtracts the specified minutes from this temporal object, returning a new instance with the subtracted minutes.
+ *
+ * @throws Exception\UnderflowException If subtracting the minutes results in an arithmetic underflow.
+ * @throws Exception\OverflowException If subtracting the minutes results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function minusMinutes(int $minutes): static;
+
+ /**
+ * Subtracts the specified seconds from this temporal object, returning a new instance with the subtracted seconds.
+ *
+ * @throws Exception\UnderflowException If subtracting the seconds results in an arithmetic underflow.
+ * @throws Exception\OverflowException If subtracting the seconds results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function minusSeconds(int $seconds): static;
+
+ /**
+ * Subtracts the specified nanoseconds from this temporal object, returning a new instance with the subtracted nanoseconds.
+ *
+ * @throws Exception\UnderflowException If subtracting the nanoseconds results in an arithmetic underflow.
+ * @throws Exception\OverflowException If subtracting the nanoseconds results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function minusNanoseconds(int $nanoseconds): static;
+
+ /**
+ * Calculates the duration between this temporal object and the given one.
+ *
+ * @param TemporalInterface $other The temporal object to calculate the duration to.
+ *
+ * @return Duration The duration between the two temporal objects.
+ *
+ * @mutation-free
+ */
+ public function since(TemporalInterface $other): Duration;
+
+ /**
+ * Formats this {@see TemporalInterface} instance based on specified styles, pattern, timezone, and locale.
+ *
+ * This method provides a flexible way to format the date and time representation of this {@see TemporalInterface} object,
+ * allowing for customization through various parameters. If parameters are not provided, default values are used,
+ * ensuring a sensible output even when minimal information is specified.
+ *
+ * Usage example:
+ *
+ * ```php
+ * use Psl\DateTime;
+ * use Psl\Locale;
+ * use Psl\IO;
+ *
+ * $timestamp = DateTime\Timestamp::now();
+ * $formatted = $timestamp->format(locale: Locale\Locale::Arabic);
+ *
+ * IO\write_line($formatted); //
+ * ```
+ *
+ * @param null|FormatDateStyle $date_style Specifies the style of the date portion for parsing. Defaults to {@see DateFormatStyle::default()}.
+ * @param null|FormatTimeStyle $time_style Specifies the style of the time portion for parsing. Defaults to {@see TimeFormatStyle::default()}.
+ * @param null|FormatPattern|string $pattern Specifies a custom format pattern to be used for parsing. A default, implementation-specific pattern is used if null.
+ * @param null|Timezone $timezone Determines the timezone for which the date and time are formatted. Defaults to {@see Timezone::default()}, or the instance's timezone if it's timezone-aware.
+ * @param null|Locale $locale Specifies the locale to be used for formatting locale-specific date and time components. Defaults to {@see Locale::default()}.
+ *
+ * @return string The formatted date and time string.
+ *
+ * @see https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax
+ *
+ * @mutation-free
+ */
+ public function format(null|FormatDateStyle $date_style = null, null|FormatTimeStyle $time_style = null, null|FormatPattern|string $pattern = null, null|Timezone $timezone = null, null|Locale $locale = null): 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.
+ *
+ * @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..e236ddfc
--- /dev/null
+++ b/src/Psl/DateTime/Timestamp.php
@@ -0,0 +1,299 @@
+ $nanoseconds
+ *
+ * @pure
+ */
+ private function __construct(
+ private int $seconds,
+ private int $nanoseconds,
+ ) {
+ }
+
+ /**
+ * Create a high-precision instance representing the current time using the system clock.
+ *
+ * @mutation-free
+ */
+ public static function now(): self
+ {
+ [$seconds, $nanoseconds] = Internal\system_time();
+
+ /** @psalm-suppress MissingThrowsDocblock */
+ return self::fromRaw($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.
+ *
+ * @mutation-free
+ */
+ public static function monotonic(): self
+ {
+ [$seconds, $nanoseconds] = Internal\high_resolution_time();
+
+ /** @psalm-suppress MissingThrowsDocblock */
+ return self::fromRaw($seconds, $nanoseconds);
+ }
+
+ /**
+ * Creates a timestamp from seconds and nanoseconds since the epoch.
+ *
+ * Normalizes so nanoseconds are within 0-999999999. For instance:
+ * - `fromRaw(42, -100)` becomes (41, 999999900).
+ * - `fromRaw(-42, -100)` becomes (-43, 999999900).
+ * - `fromRaw(42, 1000000100)` becomes (43, 100).
+ *
+ * @param int $seconds Seconds since the epoch.
+ * @param int $nanoseconds Additional nanoseconds to adjust by.
+ *
+ * @throws Exception\OverflowException
+ * @throws Exception\UnderflowException
+ *
+ * @pure
+ */
+ public static function fromRaw(int $seconds, int $nanoseconds = 0): Timestamp
+ {
+ // Check for potential overflow or underflow before doing any operation
+ if ($seconds === Math\INT64_MAX && $nanoseconds >= 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);
+ }
+
+ /**
+ * Parses a date and time string into an instance of {@see Timestamp} based on specified styles, pattern, timezone, and locale.
+ *
+ * This method offers a flexible way to convert a formatted date and time string back into a {@see Timestamp} object.
+ *
+ * Usage example:
+ *
+ * ```php
+ * use Psl\DateTime;
+ * use Psl\Locale;
+ *
+ * $raw_string = '2023-03-15 12:00:00';
+ * $parsed_timestamp = DateTime\Timestamp::parse(
+ * $raw_string,
+ * date_style: null, // Uses default date style
+ * time_style: null, // Uses default time style
+ * pattern: 'yyyy-MM-dd HH:mm:ss', // Custom pattern matching the input string
+ * timezone: DateTime\Timezone::Utc, // Parsing according to Utc timezone
+ * locale: Locale\Locale::English // Parsing using the English locale
+ * );
+ * ```
+ *
+ * @param string $raw_string The date and time string to parse.
+ * @param null|FormatDateStyle $date_style Specifies the style of the date portion for parsing. Defaults to {@see DateFormatStyle::default()}.
+ * @param null|FormatTimeStyle $time_style Specifies the style of the time portion for parsing. Defaults to {@see TimeFormatStyle::default()}.
+ * @param null|FormatPattern|string $pattern Specifies a custom format pattern to be used for parsing. A default, implementation-specific pattern is used if null.
+ * @param null|Timezone $timezone Determines the timezone for which the date and time are parsed. Defaults to {@see Timezone::default()}.
+ * @param null|Locale $locale Specifies the locale to be used for parsing locale-specific date and time components. Defaults to {@see Locale::default()}.
+ *
+ * @throws Exception\RuntimeException If failed to parse the given raw string.
+ *
+ * @return static Returns an instance of {@see Timestamp} representing the parsed date and time.
+ *
+ * @mutation-free
+ *
+ *@see https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax
+ */
+ public static function parse(string $raw_string, null|FormatDateStyle $date_style = null, null|FormatTimeStyle $time_style = null, null|FormatPattern|string $pattern = null, null|Timezone $timezone = null, null|Locale $locale = null): static
+ {
+ $formatter = Internal\create_intl_date_formatter($date_style, $time_style, $pattern, $timezone, $locale);
+
+ $timestamp = $formatter->parse($raw_string);
+ if ($timestamp === false) {
+ // Only show pattern in the exception if it was provided.
+ // TODO(azjezz): should we have `ParserException` which contains all passed parameters ( styles, locale, timezone, pattern .. etc)?
+ if (null !== $pattern) {
+ $formatter_pattern = $pattern instanceof FormatPattern ? $pattern->value : $pattern;
+
+ throw new Exception\RuntimeException(Str\format(
+ 'Parsing error: Unable to interpret \'%s\' as a valid date/time using pattern \'%s\'.',
+ $raw_string,
+ $formatter_pattern,
+ ));
+ }
+
+ throw new Exception\RuntimeException(
+ "Parsing error: Unable to interpret '$raw_string' as a valid date/time.",
+ );
+ }
+
+ /** @psalm-suppress MissingThrowsDocblock */
+ return self::fromRaw((int) $timestamp);
+ }
+
+ /**
+ * Returns this Timestamp instance itself, as it already represents a timestamp.
+ *
+ * @mutation-free
+ */
+ public function getTimestamp(): self
+ {
+ return $this;
+ }
+
+ /**
+ * Returns the raw seconds and nanoseconds of the timestamp as an array.
+ *
+ * @return array{int, int<0, 999999999>}
+ *
+ * @mutation-free
+ */
+ public function toRaw(): array
+ {
+ return [$this->seconds, $this->nanoseconds];
+ }
+
+ /**
+ * Returns the number of seconds since the Unix epoch represented by this timestamp.
+ *
+ * @return int Seconds since the epoch. Can be negative for times before the epoch.
+ *
+ * @mutation-free
+ */
+ public function getSeconds(): int
+ {
+ return $this->seconds;
+ }
+
+ /**
+ * Returns the nanoseconds part of this timestamp.
+ *
+ * @return int<0, 999999999> The nanoseconds part, ranging from 0 to 999999999.
+ *
+ * @mutation-free
+ */
+ public function getNanoseconds(): int
+ {
+ return $this->nanoseconds;
+ }
+
+ /**
+ * Adds the specified duration to this timestamp object, returning a new instance with the added duration.
+ *
+ * @throws Exception\UnderflowException If adding the duration results in an arithmetic underflow.
+ * @throws Exception\OverflowException If adding the duration results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function plus(Duration $duration): static
+ {
+ [$h, $m, $s, $ns] = $duration->getParts();
+ $totalSeconds = SECONDS_PER_MINUTE * $m + SECONDS_PER_HOUR * $h + $s;
+ $newSeconds = $this->seconds + $totalSeconds;
+ $newNanoseconds = $this->nanoseconds + $ns;
+
+ // No manual normalization required here due to fromRaw handling it
+ return self::fromRaw($newSeconds, $newNanoseconds);
+ }
+
+ /**
+ * Subtracts the specified duration from this timestamp object, returning a new instance with the subtracted duration.
+ *
+ * @throws Exception\UnderflowException If subtracting the duration results in an arithmetic underflow.
+ * @throws Exception\OverflowException If subtracting the duration results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function minus(Duration $duration): static
+ {
+ [$h, $m, $s, $ns] = $duration->getParts();
+ $totalSeconds = SECONDS_PER_MINUTE * $m + SECONDS_PER_HOUR * $h + $s;
+ $newSeconds = $this->seconds - $totalSeconds;
+ $newNanoseconds = $this->nanoseconds - $ns;
+
+ // No manual normalization required here due to fromRaw handling it
+ return self::fromRaw($newSeconds, $newNanoseconds);
+ }
+
+ /**
+ * Formats this {@see TemporalInterface} instance based on specified styles, pattern, timezone, and locale.
+ *
+ * This method provides a flexible way to format the date and time representation of this {@see TemporalInterface} object,
+ * allowing for customization through various parameters. If parameters are not provided, default values are used,
+ * ensuring a sensible output even when minimal information is specified.
+ *
+ * Usage example:
+ *
+ * ```php
+ * use Psl\DateTime;
+ * use Psl\Locale;
+ * use Psl\IO;
+ *
+ * $timestamp = DateTime\Timestamp::now();
+ * $formatted = $timestamp->format(locale: Locale\Locale::Arabic);
+ *
+ * IO\write_line($formatted); //
+ * ```
+ *
+ * @param null|FormatDateStyle $date_style Specifies the style of the date portion for parsing. Defaults to {@see DateFormatStyle::default()}.
+ * @param null|FormatTimeStyle $time_style Specifies the style of the time portion for parsing. Defaults to {@see TimeFormatStyle::default()}.
+ * @param null|FormatPattern|string $pattern Specifies a custom format pattern to be used for parsing. A default, implementation-specific pattern is used if null.
+ * @param null|Timezone $timezone Determines the timezone for which the date and time are formatted. Defaults to {@see Timezone::default()}.
+ * @param null|Locale $locale Specifies the locale to be used for formatting locale-specific date and time components. Defaults to {@see Locale::default()}.
+ *
+ * @return string The formatted date and time string.
+ *
+ * @see https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax
+ *
+ * @mutation-free
+ */
+ public function format(null|FormatDateStyle $date_style = null, null|FormatTimeStyle $time_style = null, null|FormatPattern|string $pattern = null, null|Timezone $timezone = null, null|Locale $locale = null): string
+ {
+ /** @psalm-suppress InvalidOperand */
+ return Internal\create_intl_date_formatter($date_style, $time_style, $pattern, $timezone, $locale)
+ ->format($this->getSeconds() + ($this->getNanoseconds() / NANOSECONDS_PER_SECOND));
+ }
+
+ 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..990520bb
--- /dev/null
+++ b/src/Psl/DateTime/Timezone.php
@@ -0,0 +1,590 @@
+getTimestamp()->getSeconds() * MILLISECONDS_PER_SECOND;
+ $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.
+ *
+ * @mutation-free
+ */
+ public function getRawOffset(): Duration
+ {
+ 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.
+ *
+ * @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;
+ $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.
+ *
+ * @mutation-free
+ */
+ public function usesDaylightSavingTime(): bool
+ {
+ 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.
+ *
+ * @mutation-free
+ */
+ public function getDaylightSavingTimeSavings(): Duration
+ {
+ return Duration::milliseconds(Internal\to_intl_timezone($this)->getDSTSavings());
+ }
+
+ /**
+ * Determines whether the current timezone has the same rules as another specified timezone.
+ *
+ * @mutation-free
+ */
+ public function hasTheSameRulesAs(Timezone $other): bool
+ {
+ 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..cbf09469
--- /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.
+ *
+ * @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/ZonedTemporalConvenienceMethodsTrait.php b/src/Psl/DateTime/ZonedTemporalConvenienceMethodsTrait.php
new file mode 100644
index 00000000..13971901
--- /dev/null
+++ b/src/Psl/DateTime/ZonedTemporalConvenienceMethodsTrait.php
@@ -0,0 +1,556 @@
+equals($other) && $this->getTimezone() === $other->getTimezone();
+ }
+
+ /**
+ * Returns a new instance with the specified year.
+ *
+ * @throws Exception\InvalidArgumentException If changing the year would result in an invalid date (e.g., setting a year that turns February 29th into a date in a non-leap year).
+ *
+ * @mutation-free
+ */
+ public function withYear(int $year): static
+ {
+ return $this->withDate($year, $this->getMonth(), $this->getDay());
+ }
+
+ /**
+ * Returns a new instance with the specified month.
+ *
+ * @param Month|int<1, 12> $month
+ *
+ * @throws Exception\InvalidArgumentException If changing the month would result in an invalid date/time.
+ *
+ * @mutation-free
+ */
+ public function withMonth(Month|int $month): static
+ {
+ return $this->withDate($this->getYear(), $month, $this->getDay());
+ }
+
+ /**
+ * Returns a new instance with the specified day.
+ *
+ * @param int<1, 31> $day
+ *
+ * @throws Exception\InvalidArgumentException If changing the day would result in an invalid date/time.
+ *
+ * @mutation-free
+ */
+ public function withDay(int $day): static
+ {
+ return $this->withDate($this->getYear(), $this->getMonth(), $day);
+ }
+
+ /**
+ * Returns a new instance with the specified hours.
+ *
+ * @param int<0, 23> $hours
+ *
+ * @throws Exception\InvalidArgumentException If specifying the hours would result in an invalid time (e.g., hours greater than 23).
+ *
+ * @mutation-free
+ */
+ public function withHours(int $hours): static
+ {
+ return $this->withTime($hours, $this->getMinutes(), $this->getSeconds(), $this->getNanoseconds());
+ }
+
+ /**
+ * Returns a new instance with the specified minutes.
+ *
+ * @param int<0, 59> $minutes
+ *
+ * @throws Exception\InvalidArgumentException If specifying the minutes would result in an invalid time (e.g., minutes greater than 59).
+ *
+ * @mutation-free
+ */
+ public function withMinutes(int $minutes): static
+ {
+ return $this->withTime($this->getHours(), $minutes, $this->getSeconds(), $this->getNanoseconds());
+ }
+
+ /**
+ * Returns a new instance with the specified seconds.
+ *
+ * @param int<0, 59> $seconds
+ *
+ * @throws Exception\InvalidArgumentException If specifying the seconds would result in an invalid time (e.g., seconds greater than 59).
+ *
+ * @mutation-free
+ */
+ public function withSeconds(int $seconds): static
+ {
+ return $this->withTime($this->getHours(), $this->getMinutes(), $seconds, $this->getNanoseconds());
+ }
+
+ /**
+ * Returns a new instance with the specified nanoseconds.
+ *
+ * @param int<0, 999999999> $nanoseconds
+ *
+ * @throws Exception\InvalidArgumentException If specifying the nanoseconds would result in an invalid time, considering that valid nanoseconds range from 0 to 999,999,999.
+ *
+ * @mutation-free
+ */
+ public function withNanoseconds(int $nanoseconds): static
+ {
+ return $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.
+ *
+ * @mutation-free
+ */
+ public function getDate(): array
+ {
+ return [$this->getYear(), $this->getMonth(), $this->getDay()];
+ }
+
+ /**
+ * Returns the time (hours, minutes, seconds, nanoseconds).
+ *
+ * @return array{
+ * int<0, 23>,
+ * int<0, 59>,
+ * int<0, 59>,
+ * int<0, 999999999>,
+ * }
+ *
+ * @mutation-free
+ */
+ public function getTime(): array
+ {
+ return [
+ $this->getHours(),
+ $this->getMinutes(),
+ $this->getSeconds(),
+ $this->getNanoseconds(),
+ ];
+ }
+
+ /**
+ * Returns the date and time parts (year, month, day, hours, minutes, seconds, nanoseconds).
+ *
+ * @return array{
+ * int,
+ * int<1, 12>,
+ * int<1, 31>,
+ * int<0, 23>,
+ * int<0, 59>,
+ * int<0, 59>,
+ * int<0, 999999999>,
+ * }
+ *
+ * @mutation-free
+ */
+ public function getParts(): array
+ {
+ return [
+ $this->getYear(),
+ $this->getMonth(),
+ $this->getDay(),
+ $this->getHours(),
+ $this->getMinutes(),
+ $this->getSeconds(),
+ $this->getNanoseconds(),
+ ];
+ }
+
+ /**
+ * Retrieves the era of the date represented by this DateTime instance.
+ *
+ * This method returns an instance of the `Era` enum, which indicates whether the date
+ * falls in the Anno Domini (AD) or Before Christ (BC) era. The era is determined based on the year
+ * of the date this object represents, with years designated as BC being negative
+ * and years in AD being positive.
+ *
+ * @mutation-free
+ */
+ public function getEra(): Era
+ {
+ return Era::fromYear($this->getYear());
+ }
+
+ /**
+ * Returns the century number for the year stored in this object.
+ *
+ * @mutation-free
+ */
+ public function getCentury(): int
+ {
+ return (int)($this->getYear() / 100) + 1;
+ }
+
+ /**
+ * Returns the short format of the year (last 2 digits).
+ *
+ * @return int<-99, 99> The short format of the year.
+ *
+ * @mutation-free
+ */
+ public function getYearShort(): int
+ {
+ return $this->getYear() % 100;
+ }
+
+ /**
+ * Returns the hours using the 12-hour format (1 to 12) along with the meridiem indicator.
+ *
+ * @return array{int<1, 12>, Meridiem} The hours and meridiem indicator.
+ *
+ * @mutation-free
+ */
+ public function getTwelveHours(): array
+ {
+ return [
+ ($this->getHours() % 12 ?: 12),
+ ($this->getHours() < 12 ? Meridiem::AnteMeridiem : Meridiem::PostMeridiem),
+ ];
+ }
+
+ /**
+ * Retrieves the ISO-8601 year and week number corresponding to the date.
+ *
+ * This method returns an array consisting of two integers: the first represents the year, and the second
+ * represents the week number according to ISO-8601 standards, which ranges from 1 to 53. The week numbering
+ * follows the ISO-8601 specification, where a week starts on a Monday and the first week of the year is the
+ * one that contains at least four days of the new year.
+ *
+ * Due to the ISO-8601 week numbering rules, the returned year might not always match the Gregorian year
+ * obtained from `$this->getYear()`. Specifically:
+ *
+ * - The first few days of January might belong to the last week of the previous year if they fall before
+ * the first Thursday of January.
+ *
+ * - Conversely, the last days of December might be part of the first week of the following year if they
+ * extend beyond the last Thursday of December.
+ *
+ * Examples:
+ * - For the date 2020-01-01, it returns [2020, 1], indicating the first week of 2020.
+ * - For the date 2021-01-01, it returns [2020, 53], showing that this day is part of the last week of 2020
+ * according to ISO-8601.
+ *
+ * @return array{int, int<1, 53>}
+ *
+ * @mutation-free
+ */
+ public function getISOWeekNumber(): array
+ {
+ $year = $this->getYear();
+ /** @var int<1, 53> $week */
+ $week = (int)$this->format(pattern: 'W'); // Correct format specifier for ISO-8601 week number is 'W', not '%V'
+
+ // Adjust the year based on ISO week numbering rules
+ if ($week === 1 && $this->getMonth() === 12) {
+ ++$year; // Belongs to the first week of the next year
+ } elseif ($week > 50 && $this->getMonth() === 1) {
+ --$year; // Belongs to the last week of the previous year
+ }
+
+ return [$year, $week];
+ }
+
+ /**
+ * Gets the weekday of the date.
+ *
+ * @return Weekday The weekday.
+ *
+ * @mutation-free
+ */
+ public function getWeekday(): Weekday
+ {
+ return Weekday::from(((int)$this->format(pattern: '%u')) + 1);
+ }
+
+ /**
+ * Checks if the date and time is in daylight saving time.
+ *
+ * @return bool True if in daylight saving time, false otherwise.
+ *
+ * @mutation-free
+ */
+ public function isDaylightSavingTime(): bool
+ {
+ return !$this->getTimezone()->getDaylightSavingTimeOffset($this)->isZero();
+ }
+
+ /**
+ * Returns whether the year stored in this DateTime object is a leap year
+ * (has 366 days including February 29).
+ */
+ public function isLeapYear(): bool
+ {
+ return namespace\is_leap_year($this->year);
+ }
+
+ /**
+ * Adds the specified years to this date-time object, returning a new instance with the added years.
+ *
+ * @throws Exception\UnderflowException If adding the years results in an arithmetic underflow.
+ * @throws Exception\OverflowException If adding the years results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function plusYears(int $years): static
+ {
+ if ($years < 0) {
+ return $this->minusYears(-$years);
+ }
+
+ $current_year = $this->getYear();
+
+ // Check for potential overflow when adding years
+ if ($current_year >= Math\INT64_MAX - $years || $current_year <= Math\INT64_MIN - $years) {
+ throw new Exception\OverflowException("Adding years results in a year that exceeds the representable integer range.");
+ }
+
+ $target_year = $current_year + $years;
+
+ // Handle the day and month for the target year, considering leap years
+ $current_month = $this->getMonth();
+ $current_day = $this->getDay();
+
+ $days_in_target_month = Month::from($current_month)->getDaysForYear($target_year);
+ // February 29 adjustment for non-leap target years
+ $target_day = Math\minva($days_in_target_month, $current_day);
+
+ /** @psalm-suppress MissingThrowsDocblock */
+ return $this->withDate($target_year, $current_month, $target_day);
+ }
+
+ /**
+ * Subtracts the specified years from this date-time object, returning a new instance with the subtracted years.
+ *
+ * @throws Exception\UnderflowException If subtracting the years results in an arithmetic underflow.
+ *
+ * @mutation-free
+ */
+ public function minusYears(int $years): static
+ {
+ if ($years < 0) {
+ /** @psalm-suppress MissingThrowsDocblock */
+ return $this->plusYears(-$years);
+ }
+
+ $current_year = $this->getYear();
+
+ // Check for potential underflow when subtracting years
+ if ($current_year <= Math\INT64_MIN + $years) {
+ throw new Exception\UnderflowException("Subtracting years results in a year that underflows the representable integer range.");
+ }
+
+ $target_year = $current_year - $years;
+
+ $current_month = $this->getMonth();
+ $current_day = $this->getDay();
+
+ $days_in_target_month = Month::from($current_month)->getDaysForYear($target_year);
+ $target_day = Math\minva($current_day, $days_in_target_month);
+
+ /** @psalm-suppress MissingThrowsDocblock */
+ return $this->withDate($target_year, $current_month, $target_day);
+ }
+
+ /**
+ * Adds the specified months to this date-time object, returning a new instance with the added months.
+ *
+ * @throws Exception\UnderflowException If adding the months results in an arithmetic underflow.
+ * @throws Exception\OverflowException If adding the months results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function plusMonths(int $months): static
+ {
+ if ($months < 0) {
+ return $this->minusMonths(-$months);
+ }
+
+ $current_day = $this->getDay();
+ $current_month = $this->getMonth();
+ $current_year = $this->getYear();
+
+ if ($current_year >= Math\INT64_MAX) {
+ throw new Exception\OverflowException("Cannot add months to the maximum year.");
+ }
+
+ // Calculate target month and year
+ $total_months = $current_month + $months;
+ /** @psalm-suppress MissingThrowsDocblock */
+ $target_year = $current_year + Math\div($total_months - 1, 12);
+ if ($target_year >= Math\INT64_MAX || $target_year <= Math\INT64_MIN) {
+ throw new Exception\OverflowException("Adding months results in a year that exceeds the representable integer range.");
+ }
+
+ $target_month = $total_months % 12;
+ if ($target_month === 0) {
+ $target_month = 12;
+ --$target_year; // Adjust if the modulo brings us back to December of the previous year
+ }
+
+ // Days adjustment for end-of-month scenarios
+ $days_in_target_month = Month::from($target_month)->getDaysForYear($target_year);
+
+ $target_day = Math\minva($current_day, $days_in_target_month);
+
+ /** @psalm-suppress MissingThrowsDocblock */
+ return $this->withDate($target_year, $target_month, $target_day);
+ }
+
+ /**
+ * Subtracts the specified months from this date-time object, returning a new instance with the subtracted months.
+ *
+ * @throws Exception\UnderflowException If subtracting the months results in an arithmetic underflow.
+ * @throws Exception\OverflowException If subtracting the months results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function minusMonths(int $months): static
+ {
+ if ($months < 0) {
+ return $this->plusMonths(-$months);
+ }
+
+ $current_day = $this->getDay();
+ $current_month = $this->getMonth();
+ $current_year = $this->getYear();
+
+ /**
+ * Calculate how many years to subtract based on the months.
+ *
+ * @psalm-suppress MissingThrowsDocblock
+ */
+ $years_to_subtract = Math\div($months, 12);
+ $months_to_subtract_after_years = $months % 12;
+
+ // Check for potential underflow when subtracting months
+ if ($current_year <= Math\INT64_MIN + $years_to_subtract) {
+ throw new Exception\UnderflowException("Subtracting months results in a year that underflows the representable integer range.");
+ }
+
+ $new_year = $current_year - $years_to_subtract;
+ $new_month = $current_month - $months_to_subtract_after_years;
+
+ // Adjust if new_month goes below 1 (January)
+ if ($new_month < 1) {
+ $new_month += 12; // Cycle back to December
+ --$new_year; // Adjust the year since we cycled back
+ }
+
+ // Ensure year adjustment didn't underflow
+ if ($new_year < Math\INT64_MIN) {
+ throw new Exception\UnderflowException("Subtracting months results in a year that underflows the representable integer range.");
+ }
+
+ $days_in_new_month = Month::from($new_month)->getDaysForYear($new_year);
+ $new_day = Math\minva($current_day, $days_in_new_month);
+
+ /** @psalm-suppress MissingThrowsDocblock */
+ return $this->withDate($new_year, $new_month, $new_day);
+ }
+
+ /**
+ * Adds the specified days to this date-time object, returning a new instance with the added days.
+ *
+ * @throws Exception\UnderflowException If adding the days results in an arithmetic underflow.
+ * @throws Exception\OverflowException If adding the days results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function plusDays(int $days): static
+ {
+ return $this->plusHours(HOURS_PER_DAY * $days);
+ }
+
+ /**
+ * Subtracts the specified days from this date-time object, returning a new instance with the subtracted days.
+ *
+ * @throws Exception\UnderflowException If subtracting the days results in an arithmetic underflow.
+ * @throws Exception\OverflowException If subtracting the days results in an arithmetic overflow.
+ *
+ * @mutation-free
+ */
+ public function minusDays(int $days): static
+ {
+ return $this->plusDays(-$days);
+ }
+
+
+ /**
+ * Formats this {@see DateTimeInterface} instance based on specified styles, pattern, timezone, and locale.
+ *
+ * This method provides a flexible way to format the date and time representation of this {@see DateTimeInterface} object,
+ * allowing for customization through various parameters. If parameters are not provided, default values are used,
+ * ensuring a sensible output even when minimal information is specified.
+ *
+ * Usage example:
+ *
+ * ```php
+ * use Psl\DateTime;
+ * use Psl\Locale;
+ * use Psl\IO;
+ *
+ * $date_time = DateTime\DateTime::now();
+ * $formatted = $date_time->format(locale: Locale\Locale::Arabic);
+ *
+ * IO\write_line($formatted); //
+ * ```
+ *
+ * @param null|FormatDateStyle $date_style Specifies the style of the date portion for parsing. Defaults to {@see DateFormatStyle::default()}.
+ * @param null|FormatTimeStyle $time_style Specifies the style of the time portion for parsing. Defaults to {@see TimeFormatStyle::default()}.
+ * @param null|FormatPattern|string $pattern Specifies a custom format pattern to be used for parsing. A default, implementation-specific pattern is used if null.
+ * @param null|Timezone $timezone Determines the timezone for which the date and time are formatted. Defaults to {@see static::getTimezone()}.
+ * @param null|Locale $locale Specifies the locale to be used for formatting locale-specific date and time components. Defaults to {@see Locale::default()}.
+ *
+ * @return string The formatted date and time string.
+ *
+ * @see https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax
+ *
+ * @mutation-free
+ */
+ public function format(null|FormatDateStyle $date_style = null, null|FormatTimeStyle $time_style = null, null|FormatPattern|string $pattern = null, null|Timezone $timezone = null, null|Locale $locale = null): string
+ {
+ return $this->getTimestamp()->format($date_style, $time_style, $pattern, $timezone ?? $this->getTimezone(), $locale);
+ }
+
+ /**
+ * Converts the date and time to the specified timezone.
+ *
+ * @param Timezone $timezone The timezone to convert to.
+ *
+ * @mutation-free
+ */
+ public function convertToTimezone(Timezone $timezone): static
+ {
+ return static::fromTimestamp($timezone, $this->getTimestamp());
+ }
+}
diff --git a/src/Psl/DateTime/ZonedTemporalInterface.php b/src/Psl/DateTime/ZonedTemporalInterface.php
new file mode 100644
index 00000000..59bd43da
--- /dev/null
+++ b/src/Psl/DateTime/ZonedTemporalInterface.php
@@ -0,0 +1,8 @@
+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 623b5a5b..be42535f 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;
@@ -67,7 +68,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->tryRead($max_bytes);
}
@@ -95,7 +96,7 @@ public function tell(): int
/**
* {@inheritDoc}
*/
- public function tryWrite(string $bytes, ?float $timeout = null): int
+ public function tryWrite(string $bytes, ?Duration $timeout = null): int
{
$this->assertHandleIsOpen();
$length = strlen($this->buffer);
@@ -120,7 +121,7 @@ public function tryWrite(string $bytes, ?float $timeout = null): int
/**
* {@inheritDoc}
*/
- public function write(string $bytes, ?float $timeout = null): int
+ public function write(string $bytes, ?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 a34e4f01..8f027208 100644
--- a/src/Psl/Internal/Loader.php
+++ b/src/Psl/Internal/Loader.php
@@ -54,6 +54,22 @@ final class Loader
'Psl\\Str\\ALPHABET' => 'Psl/Str/constants.php',
'Psl\\Str\\ALPHABET_ALPHANUMERIC' => 'Psl/Str/constants.php',
'Psl\\Filesystem\\SEPARATOR' => 'Psl/Filesystem/constants.php',
+ 'Psl\\DateTime\\NANOSECONDS_PER_MICROSECOND' => 'Psl/DateTime/constants.php',
+ 'Psl\\DateTime\\NANOSECONDS_PER_MILLISECOND' => 'Psl/DateTime/constants.php',
+ 'Psl\\DateTime\\NANOSECONDS_PER_SECOND' => 'Psl/DateTime/constants.php',
+ 'Psl\\DateTime\\MICROSECONDS_PER_MILLISECOND' => 'Psl/DateTime/constants.php',
+ 'Psl\\DateTime\\MICROSECONDS_PER_SECOND' => 'Psl/DateTime/constants.php',
+ 'Psl\\DateTime\\MILLISECONDS_PER_SECOND' => 'Psl/DateTime/constants.php',
+ 'Psl\\DateTime\\SECONDS_PER_MINUTE' => 'Psl/DateTime/constants.php',
+ 'Psl\\DateTime\\SECONDS_PER_HOUR' => 'Psl/DateTime/constants.php',
+ 'Psl\\DateTime\\SECONDS_PER_DAY' => 'Psl/DateTime/constants.php',
+ 'Psl\\DateTime\\SECONDS_PER_WEEK' => 'Psl/DateTime/constants.php',
+ 'Psl\\DateTime\\MINUTES_PER_HOUR' => 'Psl/DateTime/constants.php',
+ 'Psl\\DateTime\\MINUTES_PER_DAY' => 'Psl/DateTime/constants.php',
+ 'Psl\\DateTime\\MINUTES_PER_WEEK' => 'Psl/DateTime/constants.php',
+ 'Psl\\DateTime\\HOURS_PER_DAY' => 'Psl/DateTime/constants.php',
+ 'Psl\\DateTime\\HOURS_PER_WEEK' => 'Psl/DateTime/constants.php',
+ 'Psl\\DateTime\\DAYS_PER_WEEK' => 'Psl/DateTime/constants.php',
];
public const FUNCTIONS = [
@@ -520,6 +536,12 @@ 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',
];
public const INTERFACES = [
@@ -612,6 +634,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\\DateTimeInterface' => 'Psl/DateTime/DateTimeInterface.php',
+ 'Psl\\DateTime\\TemporalInterface' => 'Psl/DateTime/TemporalInterface.php',
];
public const TRAITS = [
@@ -619,6 +644,8 @@ final class Loader
'Psl\\IO\\ReadHandleConvenienceMethodsTrait' => 'Psl/IO/ReadHandleConvenienceMethodsTrait.php',
'Psl\\IO\\WriteHandleConvenienceMethodsTrait' => 'Psl/IO/WriteHandleConvenienceMethodsTrait.php',
'Psl\\Channel\\Internal\\ChannelSideTrait' => 'Psl/Channel/Internal/ChannelSideTrait.php',
+ 'Psl\\DateTime\\DateTimeConvenienceMethodsTrait' => 'Psl/DateTime/DateTimeConvenienceMethodsTrait.php',
+ 'Psl\\DateTime\\TemporalConvenienceMethodsTrait' => 'Psl/DateTime/TemporalConvenienceMethodsTrait.php',
];
public const CLASSES = [
@@ -815,6 +842,13 @@ final class Loader
'Psl\\Range\\ToRange' => 'Psl/Range/ToRange.php',
'Psl\\Range\\BetweenRange' => 'Psl/Range/BetweenRange.php',
'Psl\\Range\\FullRange' => 'Psl/Range/FullRange.php',
+ 'Psl\\DateTime\\Exception\\InvalidArgumentException' => 'Psl/DateTime/Exception/InvalidArgumentException.php',
+ 'Psl\\DateTime\\Exception\\OverflowException' => 'Psl/DateTime/Exception/OverflowException.php',
+ 'Psl\\DateTime\\Exception\\RuntimeException' => 'Psl/DateTime/Exception/RuntimeException.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 +865,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/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 acca9b49..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()) {
@@ -23,7 +24,7 @@ function connect(string $path, ?float $timeout = null): Network\StreamSocketInte
}
// @codeCoverageIgnoreEnd
- $socket = Network\Internal\socket_connect("unix://{$path}", timeout: $timeout);
+ $socket = Network\Internal\socket_connect("unix://$path", timeout: $timeout);
/** @psalm-suppress MissingThrowsDocblock */
return new Network\Internal\Socket($socket);
diff --git a/tests/unit/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..4969b78b
--- /dev/null
+++ b/tests/unit/DateTime/DateTimeTest.php
@@ -0,0 +1,59 @@
+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::Monday, $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(InvalidArgumentException::class);
+ $this->expectExceptionMessage('The given components do not form a valid date-time.');
+
+ DateTime::fromParts(Timezone::UTC, 2024, Month::February, 4, 999, 0, 0, 1);
+ }
+}
diff --git a/tests/unit/DateTime/DurationTest.php b/tests/unit/DateTime/DurationTest.php
new file mode 100644
index 00000000..127259fb
--- /dev/null
+++ b/tests/unit/DateTime/DurationTest.php
@@ -0,0 +1,351 @@
+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->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..da966cef
--- /dev/null
+++ b/tests/unit/DateTime/EraTest.php
@@ -0,0 +1,34 @@
+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..8b398ab3
--- /dev/null
+++ b/tests/unit/DateTime/IsLeapYearTest.php
@@ -0,0 +1,33 @@
+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..77a80578
--- /dev/null
+++ b/tests/unit/DateTime/TimestampTest.php
@@ -0,0 +1,407 @@
+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::fromRaw(Math\INT64_MAX, NANOSECONDS_PER_SECOND);
+ }
+
+ public function testFromRowUnderflow(): void
+ {
+ $this->expectException(UnderflowException::class);
+ $this->expectExceptionMessage('Subtracting nanoseconds would cause an underflow.');
+
+ Timestamp::fromRaw(Math\INT64_MIN, -NANOSECONDS_PER_SECOND);
+ }
+
+ public function testFromRowSimplifiesNanoseconds(): void
+ {
+ $timestamp = Timestamp::fromRaw(0, NANOSECONDS_PER_SECOND * 20);
+
+ static::assertEquals(20, $timestamp->getSeconds());
+ static::assertEquals(0, $timestamp->getNanoseconds());
+
+ $timestamp = Timestamp::fromRaw(0, 100 + NANOSECONDS_PER_SECOND * 20);
+
+ static::assertEquals(20, $timestamp->getSeconds());
+ static::assertEquals(100, $timestamp->getNanoseconds());
+
+ $timestamp = Timestamp::fromRaw(30, -NANOSECONDS_PER_SECOND * 20);
+
+ static::assertEquals(10, $timestamp->getSeconds());
+ static::assertEquals(0, $timestamp->getNanoseconds());
+
+ $timestamp = Timestamp::fromRaw(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(Timezone::UTC, $timestamp);
+
+ static::assertSame(2024, $datetime->getYear());
+ static::assertSame(3, $datetime->getMonth());
+ static::assertSame(31, $datetime->getDay());
+ }
+
+ public function testMillisecondsArePreservedWhenParsing(): void
+ {
+ $timestamp = Timestamp::fromRaw(1711917897, 124_000_000);
+ $string_representation = $timestamp->format(pattern: FormatPattern::Iso8601);
+
+ static::assertSame('2024-03-31T20:44:57.124Z', $string_representation);
+
+ $timestamp = Timestamp::parse($string_representation, pattern: FormatPattern::Iso8601);
+
+ static::assertSame(1711917897, $timestamp->getSeconds());
+ static::assertSame(124_000_000, $timestamp->getNanoseconds());
+ }
+
+ public function testFromPatternFails(): void
+ {
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('Parsing error: Unable to interpret \'2\' as a valid date/time using pattern \'yyyy DDD\'.');
+
+ Timestamp::parse('2', pattern: FormatPattern::JulianDay);
+ }
+
+ public function testParse(): void
+ {
+ $a = Timestamp::now();
+ $string = $a->format();
+
+ $b = Timestamp::parse($string);
+
+ static::assertSame($a->getSeconds(), $b->getSeconds());
+ }
+
+ public function testParseFails(): void
+ {
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('Parsing error: 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::fromRaw($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::fromRaw(12, 10);
+ $parts = $timestamp->toRaw();
+
+ static::assertSame(12, $parts[0]);
+ static::assertSame(10, $parts[1]);
+ }
+
+ /**
+ * @return list
+ */
+ public static function provideCompare(): array
+ {
+ return [
+ [Timestamp::fromRaw(100), Timestamp::fromRaw(42), Order::Greater],
+ [Timestamp::fromRaw(42), Timestamp::fromRaw(42), Order::Equal],
+ [Timestamp::fromRaw(42), Timestamp::fromRaw(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::fromRaw(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::fromRaw(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::fromRaw(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::fromRaw(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::fromRaw(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::fromRaw(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..524bb9bd
--- /dev/null
+++ b/tests/unit/DateTime/WeekdayTest.php
@@ -0,0 +1,33 @@
+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);
});