diff --git a/docs/component/type.md b/docs/component/type.md index 81be3b1..2b0a4f3 100644 --- a/docs/component/type.md +++ b/docs/component/type.md @@ -45,6 +45,7 @@ - [num](./../../src/Psl/Type/num.php#L10) - [object](./../../src/Psl/Type/object.php#L14) - [optional](./../../src/Psl/Type/optional.php#L14) +- [positive_int](./../../src/Psl/Type/positive_int.php#L10) - [resource](./../../src/Psl/Type/resource.php#L12) - [scalar](./../../src/Psl/Type/scalar.php#L10) - [shape](./../../src/Psl/Type/shape.php#L15) diff --git a/src/Psl/Internal/Loader.php b/src/Psl/Internal/Loader.php index 4195898..c5381d4 100644 --- a/src/Psl/Internal/Loader.php +++ b/src/Psl/Internal/Loader.php @@ -392,6 +392,7 @@ final class Loader 'Psl\Type\null', 'Psl\Type\nullable', 'Psl\Type\optional', + 'Psl\Type\positive_int', 'Psl\Type\num', 'Psl\Type\object', 'Psl\Type\resource', @@ -595,6 +596,7 @@ final class Loader 'Psl\Type\Internal\NullType', 'Psl\Type\Internal\NullableType', 'Psl\Type\Internal\OptionalType', + 'Psl\Type\Internal\PositiveIntType', 'Psl\Type\Internal\NumType', 'Psl\Type\Internal\ObjectType', 'Psl\Type\Internal\ResourceType', diff --git a/src/Psl/Type/Internal/PositiveIntType.php b/src/Psl/Type/Internal/PositiveIntType.php new file mode 100644 index 0000000..4199b97 --- /dev/null +++ b/src/Psl/Type/Internal/PositiveIntType.php @@ -0,0 +1,99 @@ + + * + * @internal + */ +final class PositiveIntType extends Type\Type +{ + /** + * @param mixed $value + * + * @psalm-assert-if-true positive-int $value + */ + public function matches($value): bool + { + return is_int($value) && $value > 0; + } + + /** + * @param mixed $value + * + * @throws CoercionException + * + * @return positive-int + */ + public function coerce($value): int + { + if (is_int($value) && $value > 0) { + return $value; + } + + if (is_string($value) || (is_object($value) && method_exists($value, '__toString'))) { + $str = (string)$value; + $int = Str\to_int($str); + if (null !== $int && $int > 0) { + return $int; + } + + $trimmed = Str\trim_left($str, '0'); + $int = Str\to_int($trimmed); + if (null !== $int && $int > 0) { + return $int; + } + + // Exceptional case "000" -(trim)-> "", but we treat it as 0 + if ('' === $trimmed && '' !== $str) { + CoercionException::withValue($value, $this->toString(), $this->getTrace()); + } + } + + if (is_float($value)) { + $integer_value = (int) $value; + $reconstructed = (float) $integer_value; + if ($reconstructed === $value && $integer_value > 0) { + return $integer_value; + } + } + + throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + } + + /** + * @param mixed $value + * + * @psalm-assert positive-int $value + * + * @throws AssertException + * + * @return positive-int + */ + public function assert($value): int + { + if (is_int($value) && $value > 0) { + return $value; + } + + throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + } + + public function toString(): string + { + return 'positive-int'; + } +} diff --git a/src/Psl/Type/positive_int.php b/src/Psl/Type/positive_int.php new file mode 100644 index 0000000..2e3230e --- /dev/null +++ b/src/Psl/Type/positive_int.php @@ -0,0 +1,13 @@ + + */ +function positive_int(): TypeInterface +{ + return new Internal\PositiveIntType(); +} diff --git a/tests/Psl/Type/PositiveIntTypeTest.php b/tests/Psl/Type/PositiveIntTypeTest.php new file mode 100644 index 0000000..dae93a8 --- /dev/null +++ b/tests/Psl/Type/PositiveIntTypeTest.php @@ -0,0 +1,69 @@ + + */ +final class PositiveIntTypeTest extends TypeTest +{ + public function getType(): Type\TypeInterface + { + return Type\positive_int(); + } + + public function getValidCoercions(): iterable + { + yield [123, 123]; + yield ['123', 123]; + yield [$this->stringable('123'), 123]; + yield [$this->stringable((string) Math\INT16_MAX), Math\INT16_MAX]; + yield [$this->stringable((string) Math\INT64_MAX), Math\INT64_MAX]; + yield [(string) Math\INT64_MAX, Math\INT64_MAX]; + yield [Math\INT64_MAX, Math\INT64_MAX]; + yield ['7', 7]; + yield ['07', 7]; + yield ['007', 7]; + yield [1.0, 1]; + } + + public function getInvalidCoercions(): iterable + { + + yield [0]; + yield ['0']; + yield ['-321']; + yield [-321]; + yield ['000']; + yield [1.23]; + yield ['1.23']; + yield ['1e123']; + yield ['-1e123']; + yield ['']; + yield [[]]; + yield [[123]]; + yield [null]; + yield [false]; + yield [$this->stringable('1.23')]; + yield [$this->stringable('-007')]; + yield [$this->stringable('-321')]; + yield ['-007']; + yield ['9223372036854775808']; + yield [$this->stringable('9223372036854775808')]; + yield ['-9223372036854775809']; + yield [$this->stringable('-9223372036854775809')]; + yield ['0xFF']; + yield ['-0xFF']; + yield ['']; + } + + public function getToStringExamples(): iterable + { + yield [$this->getType(), 'positive-int']; + } +}