diff --git a/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpStanExtractorTest.php b/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpStanExtractorTest.php index 5563af2a1bf07..296f12e1fa9af 100644 --- a/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpStanExtractorTest.php +++ b/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpStanExtractorTest.php @@ -963,8 +963,6 @@ public static function pseudoTypesProvider(): iterable yield ['traitString', Type::string()]; yield ['interfaceString', Type::string()]; yield ['literalString', Type::string()]; - yield ['positiveInt', Type::int()]; - yield ['negativeInt', Type::int()]; yield ['nonEmptyArray', Type::array()]; yield ['nonEmptyList', Type::list()]; yield ['scalar', Type::union(Type::int(), Type::float(), Type::string(), Type::bool())]; @@ -972,6 +970,15 @@ public static function pseudoTypesProvider(): iterable yield ['numeric', Type::union(Type::int(), Type::float(), Type::string())]; yield ['arrayKey', Type::union(Type::int(), Type::string())]; yield ['double', Type::float()]; + + // BC layer for symfony/type-info < 7.3 + if (method_exists(Type::class, 'intRange')) { + yield ['positiveInt', Type::intRange(1)]; + yield ['negativeInt', Type::intRange(\PHP_INT_MIN, -1)]; + } else { + yield ['positiveInt', Type::int()]; + yield ['negativeInt', Type::int()]; + } } public function testDummyNamespace() @@ -1001,9 +1008,16 @@ public function testExtractorIntRangeType(string $property, ?Type $type) */ public static function intRangeTypeProvider(): iterable { - yield ['a', Type::int()]; - yield ['b', Type::nullable(Type::int())]; - yield ['c', Type::int()]; + // BC layer for symfony/type-info < 7.3 + if (method_exists(Type::class, 'intRange')) { + yield ['a', Type::intRange(0, 100)]; + yield ['b', Type::nullable(Type::intRange(\PHP_INT_MIN, 100))]; + yield ['c', Type::intRange(50, \PHP_INT_MAX)]; + } else { + yield ['a', Type::int()]; + yield ['b', Type::nullable(Type::int())]; + yield ['c', Type::int()]; + } } /** diff --git a/src/Symfony/Component/TypeInfo/CHANGELOG.md b/src/Symfony/Component/TypeInfo/CHANGELOG.md index 5eaf445c6b8a0..8a0571007e645 100644 --- a/src/Symfony/Component/TypeInfo/CHANGELOG.md +++ b/src/Symfony/Component/TypeInfo/CHANGELOG.md @@ -9,6 +9,7 @@ CHANGELOG * Deprecate constructing a `CollectionType` instance as a list that is not an array * Deprecate the third `$asList` argument of `TypeFactoryTrait::iterable()`, use `TypeFactoryTrait::list()` instead * Add type alias support in `TypeContext` and `StringTypeResolver` + * Add `IntRangeType` class that represents a range of integer values 7.2 --- diff --git a/src/Symfony/Component/TypeInfo/Tests/Type/IntRangeTypeTest.php b/src/Symfony/Component/TypeInfo/Tests/Type/IntRangeTypeTest.php new file mode 100644 index 0000000000000..e0ffb23ee7fbc --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/Type/IntRangeTypeTest.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo\Tests\Type; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\IntRangeType; + +class IntRangeTypeTest extends TestCase +{ + public function testToString() + { + $this->assertSame('int<0, max>', (string) new IntRangeType(from: 0)); + $this->assertSame('int', (string) new IntRangeType(to: 0)); + $this->assertSame('int<1, max>', (string) new IntRangeType(from: 1)); + $this->assertSame('int', (string) new IntRangeType(to: -1)); + $this->assertSame('int<-3, 5>', (string) new IntRangeType(from: -3, to: 5)); + $this->assertSame('int', (string) new IntRangeType()); + $this->assertSame('int', (string) new IntRangeType(to: 5)); + } + + public function testAccepts() + { + $this->assertFalse((new IntRangeType(from: 0, to: 5))->accepts('string')); + $this->assertFalse((new IntRangeType(from: 0, to: 5))->accepts([])); + + $this->assertFalse((new IntRangeType(from: -1, to: 5))->accepts(-3)); + $this->assertTrue((new IntRangeType(from: -1, to: 5))->accepts(0)); + $this->assertTrue((new IntRangeType(from: -1, to: 5))->accepts(2)); + $this->assertFalse((new IntRangeType(from: -1, to: 5))->accepts(6)); + + $this->assertFalse(Type::union(Type::intRange(to: -1), Type::intRange(from: 1))->accepts(0)); + } +} diff --git a/src/Symfony/Component/TypeInfo/Tests/TypeResolver/StringTypeResolverTest.php b/src/Symfony/Component/TypeInfo/Tests/TypeResolver/StringTypeResolverTest.php index bbc1ffc93b738..a530fc0161b32 100644 --- a/src/Symfony/Component/TypeInfo/Tests/TypeResolver/StringTypeResolverTest.php +++ b/src/Symfony/Component/TypeInfo/Tests/TypeResolver/StringTypeResolverTest.php @@ -98,11 +98,6 @@ public static function resolveDataProvider(): iterable yield [Type::false(), 'false']; yield [Type::int(), 'int']; yield [Type::int(), 'integer']; - yield [Type::int(), 'positive-int']; - yield [Type::int(), 'negative-int']; - yield [Type::int(), 'non-positive-int']; - yield [Type::int(), 'non-negative-int']; - yield [Type::int(), 'non-zero-int']; yield [Type::float(), 'float']; yield [Type::float(), 'double']; yield [Type::string(), 'string']; @@ -146,13 +141,21 @@ public static function resolveDataProvider(): iterable yield [Type::template('T', Type::union(Type::int(), Type::string())), 'T', $typeContextFactory->createFromClassName(DummyWithTemplates::class)]; yield [Type::template('V'), 'V', $typeContextFactory->createFromReflection(new \ReflectionMethod(DummyWithTemplates::class, 'getPrice'))]; + // int range + yield [Type::intRange(from: 1), 'positive-int']; + yield [Type::intRange(from: 0), 'non-negative-int']; + yield [Type::intRange(to: -1), 'negative-int']; + yield [Type::intRange(to: 0), 'non-positive-int']; + yield [Type::union(Type::intRange(to: -1), Type::intRange(from: 1)), 'non-zero-int']; + yield [Type::intRange(0, 100), 'int<0, 100>']; + yield [Type::intRange(), 'int']; + // nullable yield [Type::nullable(Type::int()), '?int']; // generic yield [Type::generic(Type::object(\DateTime::class), Type::string(), Type::bool()), \DateTime::class.'']; yield [Type::generic(Type::object(\DateTime::class), Type::generic(Type::object(\Stringable::class), Type::bool())), \sprintf('%s<%s>', \DateTime::class, \Stringable::class)]; - yield [Type::int(), 'int<0, 100>']; // union yield [Type::union(Type::int(), Type::string()), 'int|string']; diff --git a/src/Symfony/Component/TypeInfo/Type/BuiltinType.php b/src/Symfony/Component/TypeInfo/Type/BuiltinType.php index 71ff78b3d94ab..110196aa4a0f7 100644 --- a/src/Symfony/Component/TypeInfo/Type/BuiltinType.php +++ b/src/Symfony/Component/TypeInfo/Type/BuiltinType.php @@ -20,7 +20,7 @@ * * @template T of TypeIdentifier */ -final class BuiltinType extends Type +class BuiltinType extends Type { /** * @param T $typeIdentifier diff --git a/src/Symfony/Component/TypeInfo/Type/IntRangeType.php b/src/Symfony/Component/TypeInfo/Type/IntRangeType.php new file mode 100644 index 0000000000000..1761bb5e961bd --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Type/IntRangeType.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo\Type; + +use Symfony\Component\TypeInfo\TypeIdentifier; + +/** + * Int range type. + * + * @author Martin Rademacher + * + * @extends BuiltinType + */ +final class IntRangeType extends BuiltinType +{ + public function __construct( + private int $from = \PHP_INT_MIN, + private int $to = \PHP_INT_MAX, + ) { + parent::__construct(TypeIdentifier::INT); + } + + public function getFrom(): int + { + return $this->from; + } + + public function getTo(): int + { + return $this->to; + } + + public function accepts(mixed $value): bool + { + return parent::accepts($value) && $this->from <= $value && $value <= $this->to; + } + + public function __toString(): string + { + $min = \PHP_INT_MIN === $this->from ? 'min' : $this->from; + $max = \PHP_INT_MAX === $this->to ? 'max' : $this->to; + + return \sprintf('int<%s, %s>', $min, $max); + } +} diff --git a/src/Symfony/Component/TypeInfo/TypeFactoryTrait.php b/src/Symfony/Component/TypeInfo/TypeFactoryTrait.php index df47ff7b3ddb1..9c8056c71f556 100644 --- a/src/Symfony/Component/TypeInfo/TypeFactoryTrait.php +++ b/src/Symfony/Component/TypeInfo/TypeFactoryTrait.php @@ -17,6 +17,7 @@ use Symfony\Component\TypeInfo\Type\EnumType; use Symfony\Component\TypeInfo\Type\GenericType; use Symfony\Component\TypeInfo\Type\IntersectionType; +use Symfony\Component\TypeInfo\Type\IntRangeType; use Symfony\Component\TypeInfo\Type\NullableType; use Symfony\Component\TypeInfo\Type\ObjectType; use Symfony\Component\TypeInfo\Type\TemplateType; @@ -54,6 +55,11 @@ public static function int(): BuiltinType return self::builtin(TypeIdentifier::INT); } + public static function intRange(int $from = \PHP_INT_MIN, int $to = \PHP_INT_MAX): IntRangeType + { + return new IntRangeType($from, $to); + } + /** * @return BuiltinType */ diff --git a/src/Symfony/Component/TypeInfo/TypeResolver/StringTypeResolver.php b/src/Symfony/Component/TypeInfo/TypeResolver/StringTypeResolver.php index 5cd0819bd8b76..caebd20bd9772 100644 --- a/src/Symfony/Component/TypeInfo/TypeResolver/StringTypeResolver.php +++ b/src/Symfony/Component/TypeInfo/TypeResolver/StringTypeResolver.php @@ -135,7 +135,12 @@ private function getTypeFromNode(TypeNode $node, ?TypeContext $typeContext): Typ 'bool', 'boolean' => Type::bool(), 'true' => Type::true(), 'false' => Type::false(), - 'int', 'integer', 'positive-int', 'negative-int', 'non-positive-int', 'non-negative-int', 'non-zero-int' => Type::int(), + 'int', 'integer' => Type::int(), + 'positive-int' => Type::intRange(from: 1), + 'negative-int' => Type::intRange(to: -1), + 'non-positive-int' => Type::intRange(to: 0), + 'non-negative-int' => Type::intRange(from: 0), + 'non-zero-int' => Type::union(Type::intRange(to: -1), Type::intRange(from: 1)), 'float', 'double' => Type::float(), 'string', 'class-string', @@ -184,9 +189,27 @@ private function getTypeFromNode(TypeNode $node, ?TypeContext $typeContext): Typ if ($node instanceof GenericTypeNode) { $type = $this->getTypeFromNode($node->type, $typeContext); - // handle integer ranges as simple integers + // handle integer ranges if ($type->isIdentifiedBy(TypeIdentifier::INT)) { - return $type; + $getBoundaryFromNode = function (TypeNode $node) { + if ($node instanceof IdentifierTypeNode) { + return match ($node->name) { + 'min' => \PHP_INT_MIN, + 'max' => \PHP_INT_MAX, + default => throw new \DomainException(\sprintf('Invalid int range value "%s".', $node->name)), + }; + } + + if ($node->constExpr instanceof ConstExprIntegerNode) { + return (int) $node->constExpr->value; + } + + throw new \DomainException(\sprintf('Invalid int range expression "%s".', \get_class($node->constExpr))); + }; + + $boundaries = array_map(static fn (TypeNode $t): int => $getBoundaryFromNode($t), $node->genericTypes); + + return Type::intRange($boundaries[0], $boundaries[1]); } $variableTypes = array_map(fn (TypeNode $t): Type => $this->getTypeFromNode($t, $typeContext), $node->genericTypes);