diff --git a/README.md b/README.md index 3280eea8..252031dc 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,15 @@ final class SomeClass /** @var negative-int */ private int $negativeInteger, + /** @var int<-42, 1337> */ + private int $integerRange, + + /** @var int */ + private int $integerRangeWithMinRange, + + /** @var int<0, max> */ + private int $integerRangeWithMaxRange, + private string $string, /** @var non-empty-string */ diff --git a/src/Definition/Repository/Cache/Compiler/TypeCompiler.php b/src/Definition/Repository/Cache/Compiler/TypeCompiler.php index ebf9fa68..7861b55c 100644 --- a/src/Definition/Repository/Cache/Compiler/TypeCompiler.php +++ b/src/Definition/Repository/Cache/Compiler/TypeCompiler.php @@ -13,6 +13,7 @@ use CuyZ\Valinor\Type\Types\ArrayType; use CuyZ\Valinor\Type\Types\EnumType; use CuyZ\Valinor\Type\Types\FloatType; +use CuyZ\Valinor\Type\Types\IntegerRangeType; use CuyZ\Valinor\Type\Types\IntegerValueType; use CuyZ\Valinor\Type\Types\InterfaceType; use CuyZ\Valinor\Type\Types\IntersectionType; @@ -57,6 +58,8 @@ public function compile(Type $type): string case $type instanceof UndefinedObjectType: case $type instanceof MixedType: return "$class::get()"; + case $type instanceof IntegerRangeType: + return "new $class({$type->min()}, {$type->max()})"; case $type instanceof StringValueType: case $type instanceof IntegerValueType: $value = var_export($type->value(), true); diff --git a/src/Type/Parser/Exception/Scalar/IntegerRangeInvalidMaxValue.php b/src/Type/Parser/Exception/Scalar/IntegerRangeInvalidMaxValue.php new file mode 100644 index 00000000..85e460f6 --- /dev/null +++ b/src/Type/Parser/Exception/Scalar/IntegerRangeInvalidMaxValue.php @@ -0,0 +1,21 @@ +`, it must be either `max` or an integer value.", + 1638788172 + ); + } +} diff --git a/src/Type/Parser/Exception/Scalar/IntegerRangeInvalidMinValue.php b/src/Type/Parser/Exception/Scalar/IntegerRangeInvalidMinValue.php new file mode 100644 index 00000000..36882778 --- /dev/null +++ b/src/Type/Parser/Exception/Scalar/IntegerRangeInvalidMinValue.php @@ -0,0 +1,20 @@ +`.", + 1638788306 + ); + } +} diff --git a/src/Type/Parser/Exception/Scalar/IntegerRangeMissingComma.php b/src/Type/Parser/Exception/Scalar/IntegerRangeMissingComma.php new file mode 100644 index 00000000..3ec49152 --- /dev/null +++ b/src/Type/Parser/Exception/Scalar/IntegerRangeMissingComma.php @@ -0,0 +1,20 @@ +`.", + 1638787915 + ); + } +} diff --git a/src/Type/Parser/Exception/Scalar/IntegerRangeMissingMaxValue.php b/src/Type/Parser/Exception/Scalar/IntegerRangeMissingMaxValue.php new file mode 100644 index 00000000..e5281ce8 --- /dev/null +++ b/src/Type/Parser/Exception/Scalar/IntegerRangeMissingMaxValue.php @@ -0,0 +1,20 @@ +`.", + 1638788092 + ); + } +} diff --git a/src/Type/Parser/Exception/Scalar/IntegerRangeMissingMinValue.php b/src/Type/Parser/Exception/Scalar/IntegerRangeMissingMinValue.php new file mode 100644 index 00000000..40bc1345 --- /dev/null +++ b/src/Type/Parser/Exception/Scalar/IntegerRangeMissingMinValue.php @@ -0,0 +1,19 @@ +`.', + 1638787061 + ); + } +} diff --git a/src/Type/Parser/Exception/Scalar/ReversedValuesForIntegerRange.php b/src/Type/Parser/Exception/Scalar/ReversedValuesForIntegerRange.php new file mode 100644 index 00000000..6edc61f9 --- /dev/null +++ b/src/Type/Parser/Exception/Scalar/ReversedValuesForIntegerRange.php @@ -0,0 +1,19 @@ +`.", + 1638787061 + ); + } +} diff --git a/src/Type/Parser/Exception/Scalar/SameValueForIntegerRange.php b/src/Type/Parser/Exception/Scalar/SameValueForIntegerRange.php new file mode 100644 index 00000000..98deb410 --- /dev/null +++ b/src/Type/Parser/Exception/Scalar/SameValueForIntegerRange.php @@ -0,0 +1,19 @@ +done() || ! $stream->next() instanceof OpeningBracketToken) { + return NativeIntegerType::get(); + } + + $stream->forward(); + + if ($stream->done()) { + throw new IntegerRangeMissingMinValue(); + } + + if ($stream->next() instanceof UnknownSymbolToken) { + $min = new IntegerValueType(PHP_INT_MIN); + $stream->forward(); + } else { + $min = $stream->read(); + } + + if (! $min instanceof IntegerValueType) { + throw new IntegerRangeInvalidMinValue($min); + } + + if ($stream->done() || ! $stream->forward() instanceof CommaToken) { + throw new IntegerRangeMissingComma($min); + } + + if ($stream->done()) { + throw new IntegerRangeMissingMaxValue($min); + } + + if ($stream->next() instanceof UnknownSymbolToken) { + $max = new IntegerValueType(PHP_INT_MAX); + $stream->forward(); + } else { + $max = $stream->read(); + } + + if (! $max instanceof IntegerValueType) { + throw new IntegerRangeInvalidMaxValue($min, $max); + } + + if ($stream->done() || ! $stream->forward() instanceof ClosingBracketToken) { + throw new IntegerRangeMissingClosingBracket($min, $max); + } + + return new IntegerRangeType($min->value(), $max->value()); + } +} diff --git a/src/Type/Parser/Lexer/Token/NativeToken.php b/src/Type/Parser/Lexer/Token/NativeToken.php index 967a147f..c20a2527 100644 --- a/src/Type/Parser/Lexer/Token/NativeToken.php +++ b/src/Type/Parser/Lexer/Token/NativeToken.php @@ -10,7 +10,6 @@ use CuyZ\Valinor\Type\Types\BooleanType; use CuyZ\Valinor\Type\Types\FloatType; use CuyZ\Valinor\Type\Types\MixedType; -use CuyZ\Valinor\Type\Types\NativeIntegerType; use CuyZ\Valinor\Type\Types\NativeStringType; use CuyZ\Valinor\Type\Types\NegativeIntegerType; use CuyZ\Valinor\Type\Types\NonEmptyStringType; @@ -61,9 +60,6 @@ private static function type(string $symbol): ?Type return MixedType::get(); case 'float': return FloatType::get(); - case 'int': - case 'integer': - return NativeIntegerType::get(); case 'positive-int': return PositiveIntegerType::get(); case 'negative-int': diff --git a/src/Type/Types/Exception/InvalidIntegerRangeValue.php b/src/Type/Types/Exception/InvalidIntegerRangeValue.php new file mode 100644 index 00000000..31341d74 --- /dev/null +++ b/src/Type/Types/Exception/InvalidIntegerRangeValue.php @@ -0,0 +1,19 @@ +min()} and {$type->max()}.", + 1638785150 + ); + } +} diff --git a/src/Type/Types/IntegerRangeType.php b/src/Type/Types/IntegerRangeType.php new file mode 100644 index 00000000..ec0d5efa --- /dev/null +++ b/src/Type/Types/IntegerRangeType.php @@ -0,0 +1,114 @@ +min = $min; + $this->max = $max; + + $this->signature = sprintf( + 'int<%s, %s>', + $min > PHP_INT_MIN ? $min : 'min', + $max < PHP_INT_MAX ? $max : 'max' + ); + + if ($min === $max) { + throw new SameValueForIntegerRange($min); + } + + if ($min > $max) { + throw new ReversedValuesForIntegerRange($min, $max); + } + } + + public function accepts($value): bool + { + return is_int($value) + && $value >= $this->min + && $value <= $this->max; + } + + public function matches(Type $other): bool + { + if ($other instanceof UnionType) { + return $other->isMatchedBy($this); + } + + if ($other instanceof NativeIntegerType || $other instanceof MixedType) { + return true; + } + + if ($other instanceof IntegerValueType && $this->accepts($other->value())) { + return true; + } + + if ($other instanceof NegativeIntegerType && $this->min < 0 && $this->max < 0) { + return true; + } + + if ($other instanceof PositiveIntegerType && $this->min > 0 && $this->max > 0) { + return true; + } + + if ($other instanceof self) { + return $other->min === $this->min && $other->max === $this->max; + } + + return false; + } + + public function canCast($value): bool + { + return ! is_bool($value) && filter_var($value, FILTER_VALIDATE_INT) !== false; + } + + public function cast($value): int + { + if (! $this->canCast($value)) { + throw new CannotCastValue($value, $this); + } + + $value = (int)$value; // @phpstan-ignore-line + + if ($value < $this->min || $value > $this->max) { + throw new InvalidIntegerRangeValue($value, $this); + } + + return $value; + } + + public function min(): int + { + return $this->min; + } + + public function max(): int + { + return $this->max; + } + + public function __toString(): string + { + return $this->signature; + } +} diff --git a/tests/Functional/Definition/Repository/Cache/Compiler/TypeCompilerTest.php b/tests/Functional/Definition/Repository/Cache/Compiler/TypeCompilerTest.php index 96e6186f..2653ab1b 100644 --- a/tests/Functional/Definition/Repository/Cache/Compiler/TypeCompilerTest.php +++ b/tests/Functional/Definition/Repository/Cache/Compiler/TypeCompilerTest.php @@ -12,6 +12,7 @@ use CuyZ\Valinor\Type\Types\ClassType; use CuyZ\Valinor\Type\Types\ArrayType; use CuyZ\Valinor\Type\Types\FloatType; +use CuyZ\Valinor\Type\Types\IntegerRangeType; use CuyZ\Valinor\Type\Types\IntegerValueType; use CuyZ\Valinor\Type\Types\InterfaceType; use CuyZ\Valinor\Type\Types\IntersectionType; @@ -76,6 +77,9 @@ public function type_is_compiled_correctly_data_provider(): iterable yield [NativeIntegerType::get()]; yield [PositiveIntegerType::get()]; yield [NegativeIntegerType::get()]; + yield [new IntegerRangeType(42, 1337)]; + yield [new IntegerRangeType(-1337, -42)]; + yield [new IntegerRangeType(PHP_INT_MIN, PHP_INT_MAX)]; yield [NativeStringType::get()]; yield [NonEmptyStringType::get()]; yield [UndefinedObjectType::get()]; diff --git a/tests/Functional/Type/Parser/Lexer/NativeLexerTest.php b/tests/Functional/Type/Parser/Lexer/NativeLexerTest.php index f8e41430..ef4a648a 100644 --- a/tests/Functional/Type/Parser/Lexer/NativeLexerTest.php +++ b/tests/Functional/Type/Parser/Lexer/NativeLexerTest.php @@ -160,6 +160,31 @@ public function parse_valid_types_returns_valid_result_data_provider(): array 'transformed' => 'negative-int', 'type' => IntegerType::class, ], + 'Positive integer value' => [ + 'raw' => '1337', + 'transformed' => '1337', + 'type' => IntegerValueType::class, + ], + 'Negative integer value' => [ + 'raw' => '-1337', + 'transformed' => '-1337', + 'type' => IntegerValueType::class, + ], + 'Integer range' => [ + 'raw' => 'int<42, 1337>', + 'transformed' => 'int<42, 1337>', + 'type' => IntegerRangeType::class, + ], + 'Integer range with negative values' => [ + 'raw' => 'int<-1337, -42>', + 'transformed' => 'int<-1337, -42>', + 'type' => IntegerRangeType::class, + ], + 'Integer range with min and max values' => [ + 'raw' => 'int', + 'transformed' => 'int', + 'type' => IntegerRangeType::class, + ], 'String type' => [ 'raw' => 'string', 'transformed' => 'string', @@ -180,6 +205,16 @@ public function parse_valid_types_returns_valid_result_data_provider(): array 'transformed' => 'non-empty-string', 'type' => NonEmptyStringType::class, ], + 'String value with single quote' => [ + 'raw' => "'foo'", + 'transformed' => "'foo'", + 'type' => StringValueType::class, + ], + 'String value with double quote' => [ + 'raw' => '"foo"', + 'transformed' => '"foo"', + 'type' => StringValueType::class, + ], 'Boolean type' => [ 'raw' => 'bool', 'transformed' => 'bool', @@ -445,21 +480,6 @@ public function parse_valid_types_returns_valid_result_data_provider(): array 'transformed' => 'stdClass&DateTimeInterface', 'type' => IntersectionType::class, ], - 'String value with single quote' => [ - 'raw' => "'foo'", - 'transformed' => "'foo'", - 'type' => StringValueType::class, - ], - 'String value with double quote' => [ - 'raw' => '"foo"', - 'transformed' => '"foo"', - 'type' => StringValueType::class, - ], - 'Integer value' => [ - 'raw' => '1337', - 'transformed' => '1337', - 'type' => IntegerValueType::class, - ], ]; } @@ -734,6 +754,60 @@ public function test_shaped_array_comma_expected_but_other_symbol_throws_excepti $this->parser->parse('array{int, string]'); } + public function test_missing_min_value_for_integer_range_throws_exception(): void + { + $this->expectException(IntegerRangeMissingMinValue::class); + $this->expectExceptionCode(1638787061); + $this->expectExceptionMessage('Missing min value for integer range, its signature must match `int`.'); + + $this->parser->parse('int<'); + } + + public function test_invalid_min_value_for_integer_range_throws_exception(): void + { + $this->expectException(IntegerRangeInvalidMinValue::class); + $this->expectExceptionCode(1638787807); + $this->expectExceptionMessage('Invalid type `string` for min value of integer range, it must be either `min` or an integer value.'); + + $this->parser->parse('int'); + } + + public function test_missing_comma_for_integer_range_throws_exception(): void + { + $this->expectException(IntegerRangeMissingComma::class); + $this->expectExceptionCode(1638787915); + $this->expectExceptionMessage('Missing comma in integer range signature `int<42, ?>`.'); + + $this->parser->parse('int<42 1337>'); + } + + public function test_missing_max_value_for_integer_range_throws_exception(): void + { + $this->expectException(IntegerRangeMissingMaxValue::class); + $this->expectExceptionCode(1638788092); + $this->expectExceptionMessage('Missing max value for integer range, its signature must match `int<42, max>`.'); + + $this->parser->parse('int<42,'); + } + + public function test_invalid_max_value_for_integer_range_throws_exception(): void + { + $this->expectException(IntegerRangeInvalidMaxValue::class); + $this->expectExceptionCode(1638788172); + $this->expectExceptionMessage('Invalid type `string` for max value of integer range `int<42, ?>`, it must be either `max` or an integer value.'); + + $this->parser->parse('int<42, string>'); + } + + public function test_missing_closing_bracket_for_integer_range_throws_exception(): void + { + $this->expectException(IntegerRangeMissingClosingBracket::class); + $this->expectExceptionCode(1638788306); + $this->expectExceptionMessage('Missing closing bracket in integer range signature `int<42, 1337>`.'); + + $this->parser->parse('int<42, 1337'); + } + public function test_missing_closing_single_quote_throws_exception(): void { $this->expectException(UnknownSymbol::class); diff --git a/tests/Integration/Mapping/Type/ScalarValuesMappingTest.php b/tests/Integration/Mapping/Type/ScalarValuesMappingTest.php index 0f227be6..1780696d 100644 --- a/tests/Integration/Mapping/Type/ScalarValuesMappingTest.php +++ b/tests/Integration/Mapping/Type/ScalarValuesMappingTest.php @@ -25,6 +25,9 @@ public function test_values_are_mapped_properly(): void 'integer' => 1337, 'positiveInteger' => 1337, 'negativeInteger' => -1337, + 'integerRangeWithPositiveValue' => 1337, + 'integerRangeWithNegativeValue' => -1337, + 'integerRangeWithMinAndMax' => 42, 'integerValue' => 42, 'string' => 'foo', 'nonEmptyString' => 'bar', @@ -47,6 +50,9 @@ public function test_values_are_mapped_properly(): void self::assertSame(1337, $result->integer); self::assertSame(1337, $result->positiveInteger); self::assertSame(-1337, $result->negativeInteger); + self::assertSame(1337, $result->integerRangeWithPositiveValue); + self::assertSame(-1337, $result->integerRangeWithNegativeValue); + self::assertSame(42, $result->integerRangeWithMinAndMax); self::assertSame(42, $result->integerValue); self::assertSame('foo', $result->string); self::assertSame('bar', $result->nonEmptyString); @@ -109,6 +115,15 @@ class ScalarValues /** @var negative-int */ public int $negativeInteger = -1; + /** @var int<-1337, 1337> */ + public int $integerRangeWithPositiveValue = -1; + + /** @var int<-1337, 1337> */ + public int $integerRangeWithNegativeValue = -1; + + /** @var int */ + public int $integerRangeWithMinAndMax = -1; + /** @var 42 */ public int $integerValue; @@ -138,6 +153,9 @@ class ScalarValuesWithConstructor extends ScalarValues /** * @param positive-int $positiveInteger * @param negative-int $negativeInteger + * @param int<-1337, 1337> $integerRangeWithPositiveValue + * @param int<-1337, 1337> $integerRangeWithNegativeValue + * @param int $integerRangeWithMinAndMax * @param 42 $integerValue * @param non-empty-string $nonEmptyString * @param 'baz' $stringValueWithSingleQuote @@ -152,6 +170,9 @@ public function __construct( int $integer, int $positiveInteger, int $negativeInteger, + int $integerRangeWithPositiveValue, + int $integerRangeWithNegativeValue, + int $integerRangeWithMinAndMax, int $integerValue, string $string, string $nonEmptyString, @@ -166,6 +187,9 @@ public function __construct( $this->integer = $integer; $this->positiveInteger = $positiveInteger; $this->negativeInteger = $negativeInteger; + $this->integerRangeWithPositiveValue = $integerRangeWithPositiveValue; + $this->integerRangeWithNegativeValue = $integerRangeWithNegativeValue; + $this->integerRangeWithMinAndMax = $integerRangeWithMinAndMax; $this->integerValue = $integerValue; $this->string = $string; $this->nonEmptyString = $nonEmptyString; diff --git a/tests/Unit/Type/Types/IntegerRangeTypeTest.php b/tests/Unit/Type/Types/IntegerRangeTypeTest.php new file mode 100644 index 00000000..0b9bd09d --- /dev/null +++ b/tests/Unit/Type/Types/IntegerRangeTypeTest.php @@ -0,0 +1,212 @@ +type = new IntegerRangeType(-42, 42); + } + + public function test_range_with_same_min_and_max_throws_exception(): void + { + $this->expectException(SameValueForIntegerRange::class); + $this->expectExceptionCode(1638786927); + $this->expectExceptionMessage('The min and max values for integer range must be different, `42` was given.'); + + new IntegerRangeType(42, 42); + } + + public function test_range_with_same_min_greater_than_max_throws_exception(): void + { + $this->expectException(ReversedValuesForIntegerRange::class); + $this->expectExceptionCode(1638787061); + $this->expectExceptionMessage('The min value must be less than the max for integer range `int<1337, 42>`.'); + + new IntegerRangeType(1337, 42); + } + + public function test_accepts_correct_values(): void + { + self::assertTrue($this->type->accepts(-42)); + self::assertTrue($this->type->accepts(0)); + self::assertTrue($this->type->accepts(42)); + } + + public function test_does_not_accept_incorrect_values(): void + { + self::assertFalse($this->type->accepts(-1337)); + self::assertFalse($this->type->accepts(1337)); + self::assertFalse($this->type->accepts(null)); + self::assertFalse($this->type->accepts('Schwifty!')); + self::assertFalse($this->type->accepts(42.1337)); + self::assertFalse($this->type->accepts(['foo' => 'bar'])); + self::assertFalse($this->type->accepts(false)); + self::assertFalse($this->type->accepts(new stdClass())); + } + + public function test_can_cast_integer_value(): void + { + self::assertTrue($this->type->canCast(42)); + self::assertTrue($this->type->canCast('42')); + self::assertTrue($this->type->canCast(42.00)); + } + + public function test_cannot_cast_other_types(): void + { + self::assertFalse($this->type->canCast(null)); + self::assertFalse($this->type->canCast(42.1337)); + self::assertFalse($this->type->canCast(['foo' => 'bar'])); + self::assertFalse($this->type->canCast('Schwifty!')); + self::assertFalse($this->type->canCast(false)); + self::assertFalse($this->type->canCast(new stdClass())); + } + + /** + * @dataProvider cast_value_returns_correct_result_data_provider + * + * @param mixed $value + */ + public function test_cast_value_returns_correct_result($value, int $expected): void + { + self::assertSame($expected, $this->type->cast($value)); + } + + public function cast_value_returns_correct_result_data_provider(): array + { + return [ + 'Integer from float' => [ + 'value' => 42.00, + 'expected' => 42, + ], + 'Integer from string' => [ + 'value' => '42', + 'expected' => 42, + ], + 'Integer from integer' => [ + 'value' => 42, + 'expected' => 42, + ], + ]; + } + + public function test_cast_invalid_value_throws_exception(): void + { + $this->expectException(CannotCastValue::class); + $this->expectExceptionCode(1603216198); + $this->expectExceptionMessage("Cannot cast from `string` to `$this->type`."); + + $this->type->cast('foo'); + } + + public function test_cast_invalid_integer_value_throws_exception(): void + { + $this->expectException(InvalidIntegerRangeValue::class); + $this->expectExceptionCode(1638785150); + $this->expectExceptionMessage("Invalid value `1337`: it must be an integer between {$this->type->min()} and {$this->type->max()}."); + + $this->type->cast(1337); + } + + public function test_string_value_is_correct(): void + { + self::assertSame('int<42, 1337>', (string)new IntegerRangeType(42, 1337)); + self::assertSame('int<-1337, -42>', (string)new IntegerRangeType(-1337, -42)); + self::assertSame('int', (string)new IntegerRangeType(PHP_INT_MIN, PHP_INT_MAX)); + } + + public function test_matches_same_type_with_same_range(): void + { + self::assertTrue((new IntegerRangeType(-42, 42))->matches(new IntegerRangeType(-42, 42))); + } + + public function test_does_not_match_same_type_with_different_range(): void + { + self::assertFalse((new IntegerRangeType(-42, 42))->matches(new IntegerRangeType(-1337, 42))); + } + + public function test_matches_integer_value_when_value_is_in_range(): void + { + self::assertTrue((new IntegerRangeType(42, 1337))->matches(new IntegerValueType(42))); + } + + public function test_does_not_match_integer_value_when_value_is_not_in_range(): void + { + self::assertFalse((new IntegerRangeType(42, 1337))->matches(new IntegerValueType(-1337))); + } + + public function test_matches_positive_integer_when_range_is_positive(): void + { + self::assertTrue((new IntegerRangeType(42, 1337))->matches(new PositiveIntegerType())); + } + + public function test_does_not_match_positive_integer_when_min_is_negative(): void + { + self::assertFalse((new IntegerRangeType(-42, 1337))->matches(new PositiveIntegerType())); + } + + public function test_does_not_match_positive_integer_when_max_is_negative(): void + { + self::assertFalse((new IntegerRangeType(-1337, -42))->matches(new PositiveIntegerType())); + } + + public function test_matches_negative_integer_when_range_is_negative(): void + { + self::assertTrue((new IntegerRangeType(-1337, -42))->matches(new NegativeIntegerType())); + } + + public function test_does_not_match_negative_integer_when_min_is_positive(): void + { + self::assertFalse((new IntegerRangeType(42, 1337))->matches(new NegativeIntegerType())); + } + + public function test_does_not_match_negative_integer_when_max_is_positive(): void + { + self::assertFalse((new IntegerRangeType(-42, 1337))->matches(new NegativeIntegerType())); + } + + public function test_does_not_match_other_type(): void + { + self::assertFalse($this->type->matches(new FakeType())); + } + + public function test_matches_mixed_type(): void + { + self::assertTrue($this->type->matches(new MixedType())); + } + + public function test_matches_union_type_containing_integer_range_type(): void + { + $union = new UnionType(new FakeType(), new IntegerRangeType(-42, 42), new FakeType()); + + self::assertTrue($this->type->matches($union)); + } + + public function test_does_not_match_union_type_not_containing_integer_range_type(): void + { + $unionType = new UnionType(new FakeType(), new FakeType()); + + self::assertFalse($this->type->matches($unionType)); + } +}