From 6de7d7df0dc5fc9b88c0102f1bb28bbe556778fa Mon Sep 17 00:00:00 2001 From: Mathias Arlaud Date: Wed, 4 Oct 2023 08:57:42 +0200 Subject: [PATCH] [TypeInfo] Introduce component Co-authored-by: Baptiste Leduc --- composer.json | 1 + .../DependencyInjection/Configuration.php | 14 + .../FrameworkExtension.php | 26 ++ .../Resources/config/schema/symfony-1.0.xsd | 5 + .../Resources/config/type_info.php | 50 +++ .../DependencyInjection/ConfigurationTest.php | 4 + .../DependencyInjection/Fixtures/php/full.php | 1 + .../Fixtures/php/type_info.php | 11 + .../DependencyInjection/Fixtures/xml/full.xml | 1 + .../Fixtures/xml/type_info.xml | 13 + .../DependencyInjection/Fixtures/yml/full.yml | 1 + .../Fixtures/yml/type_info.yml | 8 + .../FrameworkExtensionTestCase.php | 6 + .../Tests/Functional/TypeInfoTest.php | 32 ++ .../Tests/Functional/app/TypeInfo/Dummy.php | 21 ++ .../Tests/Functional/app/TypeInfo/bundles.php | 18 + .../Tests/Functional/app/TypeInfo/config.yml | 11 + .../Bundle/FrameworkBundle/composer.json | 1 + src/Symfony/Component/TypeInfo/.gitattributes | 4 + src/Symfony/Component/TypeInfo/.gitignore | 3 + src/Symfony/Component/TypeInfo/CHANGELOG.md | 7 + .../TypeInfo/Exception/ExceptionInterface.php | 20 ++ .../Exception/InvalidArgumentException.php | 20 ++ .../TypeInfo/Exception/LogicException.php | 20 ++ .../TypeInfo/Exception/RuntimeException.php | 20 ++ .../Exception/UnsupportedException.php | 28 ++ src/Symfony/Component/TypeInfo/LICENSE | 19 ++ src/Symfony/Component/TypeInfo/README.md | 42 +++ .../TypeInfo/Tests/Fixtures/AbstractDummy.php | 7 + .../TypeInfo/Tests/Fixtures/Dummy.php | 18 + .../Tests/Fixtures/DummyBackedEnum.php | 9 + .../TypeInfo/Tests/Fixtures/DummyEnum.php | 9 + .../Tests/Fixtures/DummyExtendingStdClass.php | 7 + .../Tests/Fixtures/DummyWithTemplates.php | 23 ++ .../TypeInfo/Tests/Fixtures/DummyWithUses.php | 22 ++ .../Fixtures/ReflectionExtractableDummy.php | 79 +++++ .../Tests/Type/BackedEnumTypeTest.php | 44 +++ .../TypeInfo/Tests/Type/BuiltinTypeTest.php | 65 ++++ .../Tests/Type/CollectionTypeTest.php | 99 ++++++ .../TypeInfo/Tests/Type/EnumTypeTest.php | 43 +++ .../TypeInfo/Tests/Type/GenericTypeTest.php | 58 ++++ .../Tests/Type/IntersectionTypeTest.php | 99 ++++++ .../TypeInfo/Tests/Type/ObjectTypeTest.php | 35 ++ .../TypeInfo/Tests/Type/UnionTypeTest.php | 117 +++++++ .../TypeContext/TypeContextFactoryTest.php | 122 +++++++ .../Tests/TypeContext/TypeContextTest.php | 63 ++++ .../TypeInfo/Tests/TypeFactoryTest.php | 208 ++++++++++++ .../ReflectionParameterTypeResolverTest.php | 74 ++++ .../ReflectionPropertyTypeResolverTest.php | 66 ++++ .../ReflectionReturnTypeResolverTest.php | 66 ++++ .../ReflectionTypeResolverTest.php | 100 ++++++ .../TypeResolver/StringTypeResolverTest.php | 190 +++++++++++ .../Tests/TypeResolver/TypeResolverTest.php | 92 +++++ .../Component/TypeInfo/Tests/TypeTest.php | 74 ++++ src/Symfony/Component/TypeInfo/Type.php | 89 +++++ .../TypeInfo/Type/BackedEnumType.php | 45 +++ .../Component/TypeInfo/Type/BuiltinType.php | 72 ++++ .../TypeInfo/Type/CollectionType.php | 108 ++++++ .../TypeInfo/Type/CompositeTypeTrait.php | 86 +++++ .../Component/TypeInfo/Type/EnumType.php | 24 ++ .../Component/TypeInfo/Type/GenericType.php | 88 +++++ .../TypeInfo/Type/IntersectionType.php | 51 +++ .../Component/TypeInfo/Type/ObjectType.php | 55 +++ .../Component/TypeInfo/Type/TemplateType.php | 49 +++ .../Component/TypeInfo/Type/UnionType.php | 60 ++++ .../TypeInfo/TypeContext/TypeContext.php | 116 +++++++ .../TypeContext/TypeContextFactory.php | 184 ++++++++++ .../Component/TypeInfo/TypeFactoryTrait.php | 319 ++++++++++++++++++ .../Component/TypeInfo/TypeIdentifier.php | 37 ++ .../ReflectionParameterTypeResolver.php | 53 +++ .../ReflectionPropertyTypeResolver.php | 51 +++ .../ReflectionReturnTypeResolver.php | 53 +++ .../TypeResolver/ReflectionTypeResolver.php | 98 ++++++ .../TypeResolver/StringTypeResolver.php | 255 ++++++++++++++ .../TypeInfo/TypeResolver/TypeResolver.php | 100 ++++++ .../TypeResolver/TypeResolverInterface.php | 33 ++ src/Symfony/Component/TypeInfo/composer.json | 42 +++ .../Component/TypeInfo/phpunit.xml.dist | 31 ++ 78 files changed, 4295 insertions(+) create mode 100644 src/Symfony/Bundle/FrameworkBundle/Resources/config/type_info.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/type_info.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/type_info.xml create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/type_info.yml create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Functional/TypeInfoTest.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/TypeInfo/Dummy.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/TypeInfo/bundles.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/TypeInfo/config.yml create mode 100644 src/Symfony/Component/TypeInfo/.gitattributes create mode 100644 src/Symfony/Component/TypeInfo/.gitignore create mode 100644 src/Symfony/Component/TypeInfo/CHANGELOG.md create mode 100644 src/Symfony/Component/TypeInfo/Exception/ExceptionInterface.php create mode 100644 src/Symfony/Component/TypeInfo/Exception/InvalidArgumentException.php create mode 100644 src/Symfony/Component/TypeInfo/Exception/LogicException.php create mode 100644 src/Symfony/Component/TypeInfo/Exception/RuntimeException.php create mode 100644 src/Symfony/Component/TypeInfo/Exception/UnsupportedException.php create mode 100644 src/Symfony/Component/TypeInfo/LICENSE create mode 100644 src/Symfony/Component/TypeInfo/README.md create mode 100644 src/Symfony/Component/TypeInfo/Tests/Fixtures/AbstractDummy.php create mode 100644 src/Symfony/Component/TypeInfo/Tests/Fixtures/Dummy.php create mode 100644 src/Symfony/Component/TypeInfo/Tests/Fixtures/DummyBackedEnum.php create mode 100644 src/Symfony/Component/TypeInfo/Tests/Fixtures/DummyEnum.php create mode 100644 src/Symfony/Component/TypeInfo/Tests/Fixtures/DummyExtendingStdClass.php create mode 100644 src/Symfony/Component/TypeInfo/Tests/Fixtures/DummyWithTemplates.php create mode 100644 src/Symfony/Component/TypeInfo/Tests/Fixtures/DummyWithUses.php create mode 100644 src/Symfony/Component/TypeInfo/Tests/Fixtures/ReflectionExtractableDummy.php create mode 100644 src/Symfony/Component/TypeInfo/Tests/Type/BackedEnumTypeTest.php create mode 100644 src/Symfony/Component/TypeInfo/Tests/Type/BuiltinTypeTest.php create mode 100644 src/Symfony/Component/TypeInfo/Tests/Type/CollectionTypeTest.php create mode 100644 src/Symfony/Component/TypeInfo/Tests/Type/EnumTypeTest.php create mode 100644 src/Symfony/Component/TypeInfo/Tests/Type/GenericTypeTest.php create mode 100644 src/Symfony/Component/TypeInfo/Tests/Type/IntersectionTypeTest.php create mode 100644 src/Symfony/Component/TypeInfo/Tests/Type/ObjectTypeTest.php create mode 100644 src/Symfony/Component/TypeInfo/Tests/Type/UnionTypeTest.php create mode 100644 src/Symfony/Component/TypeInfo/Tests/TypeContext/TypeContextFactoryTest.php create mode 100644 src/Symfony/Component/TypeInfo/Tests/TypeContext/TypeContextTest.php create mode 100644 src/Symfony/Component/TypeInfo/Tests/TypeFactoryTest.php create mode 100644 src/Symfony/Component/TypeInfo/Tests/TypeResolver/ReflectionParameterTypeResolverTest.php create mode 100644 src/Symfony/Component/TypeInfo/Tests/TypeResolver/ReflectionPropertyTypeResolverTest.php create mode 100644 src/Symfony/Component/TypeInfo/Tests/TypeResolver/ReflectionReturnTypeResolverTest.php create mode 100644 src/Symfony/Component/TypeInfo/Tests/TypeResolver/ReflectionTypeResolverTest.php create mode 100644 src/Symfony/Component/TypeInfo/Tests/TypeResolver/StringTypeResolverTest.php create mode 100644 src/Symfony/Component/TypeInfo/Tests/TypeResolver/TypeResolverTest.php create mode 100644 src/Symfony/Component/TypeInfo/Tests/TypeTest.php create mode 100644 src/Symfony/Component/TypeInfo/Type.php create mode 100644 src/Symfony/Component/TypeInfo/Type/BackedEnumType.php create mode 100644 src/Symfony/Component/TypeInfo/Type/BuiltinType.php create mode 100644 src/Symfony/Component/TypeInfo/Type/CollectionType.php create mode 100644 src/Symfony/Component/TypeInfo/Type/CompositeTypeTrait.php create mode 100644 src/Symfony/Component/TypeInfo/Type/EnumType.php create mode 100644 src/Symfony/Component/TypeInfo/Type/GenericType.php create mode 100644 src/Symfony/Component/TypeInfo/Type/IntersectionType.php create mode 100644 src/Symfony/Component/TypeInfo/Type/ObjectType.php create mode 100644 src/Symfony/Component/TypeInfo/Type/TemplateType.php create mode 100644 src/Symfony/Component/TypeInfo/Type/UnionType.php create mode 100644 src/Symfony/Component/TypeInfo/TypeContext/TypeContext.php create mode 100644 src/Symfony/Component/TypeInfo/TypeContext/TypeContextFactory.php create mode 100644 src/Symfony/Component/TypeInfo/TypeFactoryTrait.php create mode 100644 src/Symfony/Component/TypeInfo/TypeIdentifier.php create mode 100644 src/Symfony/Component/TypeInfo/TypeResolver/ReflectionParameterTypeResolver.php create mode 100644 src/Symfony/Component/TypeInfo/TypeResolver/ReflectionPropertyTypeResolver.php create mode 100644 src/Symfony/Component/TypeInfo/TypeResolver/ReflectionReturnTypeResolver.php create mode 100644 src/Symfony/Component/TypeInfo/TypeResolver/ReflectionTypeResolver.php create mode 100644 src/Symfony/Component/TypeInfo/TypeResolver/StringTypeResolver.php create mode 100644 src/Symfony/Component/TypeInfo/TypeResolver/TypeResolver.php create mode 100644 src/Symfony/Component/TypeInfo/TypeResolver/TypeResolverInterface.php create mode 100644 src/Symfony/Component/TypeInfo/composer.json create mode 100644 src/Symfony/Component/TypeInfo/phpunit.xml.dist diff --git a/composer.json b/composer.json index 605e94ac8447f..e9b9ae71d3ee3 100644 --- a/composer.json +++ b/composer.json @@ -109,6 +109,7 @@ "symfony/translation": "self.version", "symfony/twig-bridge": "self.version", "symfony/twig-bundle": "self.version", + "symfony/type-info": "self.version", "symfony/uid": "self.version", "symfony/validator": "self.version", "symfony/var-dumper": "self.version", diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index 702b4f6109077..81ee3e06e0546 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -44,6 +44,7 @@ use Symfony\Component\Serializer\Encoder\JsonDecode; use Symfony\Component\Serializer\Serializer; use Symfony\Component\Translation\Translator; +use Symfony\Component\TypeInfo\Type; use Symfony\Component\Uid\Factory\UuidFactory; use Symfony\Component\Validator\Validation; use Symfony\Component\Webhook\Controller\WebhookController; @@ -164,6 +165,7 @@ public function getConfigTreeBuilder(): TreeBuilder $this->addAnnotationsSection($rootNode); $this->addSerializerSection($rootNode, $enableIfStandalone); $this->addPropertyAccessSection($rootNode, $willBeAvailable); + $this->addTypeInfoSection($rootNode, $enableIfStandalone); $this->addPropertyInfoSection($rootNode, $enableIfStandalone); $this->addCacheSection($rootNode, $willBeAvailable); $this->addPhpErrorsSection($rootNode); @@ -1162,6 +1164,18 @@ private function addPropertyInfoSection(ArrayNodeDefinition $rootNode, callable ; } + private function addTypeInfoSection(ArrayNodeDefinition $rootNode, callable $enableIfStandalone): void + { + $rootNode + ->children() + ->arrayNode('type_info') + ->info('Type info configuration') + ->{$enableIfStandalone('symfony/type-info', Type::class)}() + ->end() + ->end() + ; + } + private function addCacheSection(ArrayNodeDefinition $rootNode, callable $willBeAvailable): void { $rootNode diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 9c9446fb03ce0..933c141e10fed 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -53,6 +53,7 @@ use Symfony\Component\Console\Debug\CliRequest; use Symfony\Component\Console\Messenger\RunCommandMessageHandler; use Symfony\Component\DependencyInjection\Alias; +use Symfony\Component\DependencyInjection\Argument\ServiceLocatorArgument; use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -167,6 +168,8 @@ use Symfony\Component\Translation\LocaleSwitcher; use Symfony\Component\Translation\PseudoLocalizationTranslator; use Symfony\Component\Translation\Translator; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeResolver\StringTypeResolver; use Symfony\Component\Uid\Factory\UuidFactory; use Symfony\Component\Uid\UuidV4; use Symfony\Component\Validator\Constraints\ExpressionLanguageProvider; @@ -388,6 +391,10 @@ public function load(array $configs, ContainerBuilder $container): void $container->removeDefinition('console.command.serializer_debug'); } + if ($this->readConfigEnabled('type_info', $container, $config['type_info'])) { + $this->registerTypeInfoConfiguration($container, $loader); + } + if ($propertyInfoEnabled) { $this->registerPropertyInfoConfiguration($container, $loader); } @@ -1953,6 +1960,25 @@ private function registerPropertyInfoConfiguration(ContainerBuilder $container, } } + private function registerTypeInfoConfiguration(ContainerBuilder $container, PhpFileLoader $loader): void + { + if (!class_exists(Type::class)) { + throw new LogicException('TypeInfo support cannot be enabled as the TypeInfo component is not installed. Try running "composer require symfony/type-info".'); + } + + $loader->load('type_info.php'); + + if (ContainerBuilder::willBeAvailable('phpstan/phpdoc-parser', PhpDocParser::class, ['symfony/framework-bundle', 'symfony/type-info'])) { + $container->register('type_info.resolver.string', StringTypeResolver::class); + + /** @var ServiceLocatorArgument $resolversLocator */ + $resolversLocator = $container->getDefinition('type_info.resolver')->getArgument(0); + $resolversLocator->setValues($resolversLocator->getValues() + [ + 'string' => new Reference('type_info.resolver.string'), + ]); + } + } + private function registerLockConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void { $loader->load('lock.php'); diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd index 9279eaf68755b..6ebcfedbf7ad8 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd @@ -27,6 +27,7 @@ + @@ -327,6 +328,10 @@ + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/type_info.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/type_info.php new file mode 100644 index 0000000000000..71e3646a1e041 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/type_info.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use Symfony\Component\TypeInfo\TypeContext\TypeContextFactory; +use Symfony\Component\TypeInfo\TypeResolver\ReflectionParameterTypeResolver; +use Symfony\Component\TypeInfo\TypeResolver\ReflectionPropertyTypeResolver; +use Symfony\Component\TypeInfo\TypeResolver\ReflectionReturnTypeResolver; +use Symfony\Component\TypeInfo\TypeResolver\ReflectionTypeResolver; +use Symfony\Component\TypeInfo\TypeResolver\TypeResolver; +use Symfony\Component\TypeInfo\TypeResolver\TypeResolverInterface; + +return static function (ContainerConfigurator $container) { + $container->services() + // type context + ->set('type_info.type_context_factory', TypeContextFactory::class) + ->args([service('type_info.resolver.string')->nullOnInvalid()]) + + // type resolvers + ->set('type_info.resolver', TypeResolver::class) + ->args([service_locator([ + \ReflectionType::class => service('type_info.resolver.reflection_type'), + \ReflectionParameter::class => service('type_info.resolver.reflection_parameter'), + \ReflectionProperty::class => service('type_info.resolver.reflection_property'), + \ReflectionFunctionAbstract::class => service('type_info.resolver.reflection_return'), + ])]) + ->alias(TypeResolverInterface::class, 'type_info.resolver') + + ->set('type_info.resolver.reflection_type', ReflectionTypeResolver::class) + ->args([service('type_info.type_context_factory')]) + + ->set('type_info.resolver.reflection_parameter', ReflectionParameterTypeResolver::class) + ->args([service('type_info.resolver.reflection_type'), service('type_info.type_context_factory')]) + + ->set('type_info.resolver.reflection_property', ReflectionPropertyTypeResolver::class) + ->args([service('type_info.resolver.reflection_type'), service('type_info.type_context_factory')]) + + ->set('type_info.resolver.reflection_return', ReflectionReturnTypeResolver::class) + ->args([service('type_info.resolver.reflection_type'), service('type_info.type_context_factory')]) + ; +}; diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php index d56cfa90d7f48..5a4005bbf2722 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -28,6 +28,7 @@ use Symfony\Component\RateLimiter\Policy\TokenBucketLimiter; use Symfony\Component\Scheduler\Messenger\SchedulerTransportFactory; use Symfony\Component\Serializer\Encoder\JsonDecode; +use Symfony\Component\TypeInfo\Type; use Symfony\Component\Uid\Factory\UuidFactory; class ConfigurationTest extends TestCase @@ -624,6 +625,9 @@ protected static function getBundleDefaultConfig() 'throw_exception_on_invalid_index' => false, 'throw_exception_on_invalid_property_path' => true, ], + 'type_info' => [ + 'enabled' => !class_exists(FullStack::class) && class_exists(Type::class), + ], 'property_info' => [ 'enabled' => !class_exists(FullStack::class), ], diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/full.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/full.php index b5d8061e4d0af..4fbf72a9f6eea 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/full.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/full.php @@ -70,6 +70,7 @@ 'default_context' => ['enable_max_depth' => true], ], 'property_info' => true, + 'type_info' => true, 'ide' => 'file%%link%%format', 'request' => [ 'formats' => [ diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/type_info.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/type_info.php new file mode 100644 index 0000000000000..0e7dcbae0e1da --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/type_info.php @@ -0,0 +1,11 @@ +loadFromExtension('framework', [ + 'annotations' => false, + 'http_method_override' => false, + 'handle_all_throwables' => true, + 'php_errors' => ['log' => true], + 'type_info' => [ + 'enabled' => true, + ], +]); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/full.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/full.xml index 92e4405a003fd..fd5d52e1c5de5 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/full.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/full.xml @@ -40,5 +40,6 @@ + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/type_info.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/type_info.xml new file mode 100644 index 0000000000000..0fe4d525d1d5c --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/type_info.xml @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/full.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/full.yml index 883e9d6c20ebb..96001f1d2dc88 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/full.yml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/full.yml @@ -59,6 +59,7 @@ framework: max_depth_handler: my.max.depth.handler default_context: enable_max_depth: true + type_info: ~ property_info: ~ ide: file%%link%%format request: diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/type_info.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/type_info.yml new file mode 100644 index 0000000000000..4d6b405b28821 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/type_info.yml @@ -0,0 +1,8 @@ +framework: + annotations: false + http_method_override: false + handle_all_throwables: true + php_errors: + log: true + type_info: + enabled: true diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php index 0d97dcf62fae3..8c4711c641c91 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php @@ -1616,6 +1616,12 @@ public function testSerializerServiceIsNotRegisteredWhenDisabled() $this->assertFalse($container->hasDefinition('serializer')); } + public function testTypeInfoEnabled() + { + $container = $this->createContainerFromFile('type_info'); + $this->assertTrue($container->has('type_info.resolver')); + } + public function testPropertyInfoEnabled() { $container = $this->createContainerFromFile('property_info'); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/TypeInfoTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/TypeInfoTest.php new file mode 100644 index 0000000000000..6acdb9c814548 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/TypeInfoTest.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Tests\Functional; + +use PHPStan\PhpDocParser\Parser\PhpDocParser; +use Symfony\Bundle\FrameworkBundle\Tests\Functional\app\TypeInfo\Dummy; +use Symfony\Component\TypeInfo\Type; + +class TypeInfoTest extends AbstractWebTestCase +{ + public function testComponent() + { + static::bootKernel(['test_case' => 'TypeInfo']); + + $this->assertEquals(Type::string(), static::getContainer()->get('type_info.resolver')->resolve(new \ReflectionProperty(Dummy::class, 'name'))); + + if (!class_exists(PhpDocParser::class)) { + $this->markTestSkipped('"phpstan/phpdoc-parser" dependency is required.'); + } + + $this->assertEquals(Type::int(), static::getContainer()->get('type_info.resolver')->resolve('int')); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/TypeInfo/Dummy.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/TypeInfo/Dummy.php new file mode 100644 index 0000000000000..0f517df5139d0 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/TypeInfo/Dummy.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Tests\Functional\app\TypeInfo; + +/** + * @author Mathias Arlaud + * @author Baptiste Leduc + */ +class Dummy +{ + public string $name; +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/TypeInfo/bundles.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/TypeInfo/bundles.php new file mode 100644 index 0000000000000..15ff182c6fed5 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/TypeInfo/bundles.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Bundle\FrameworkBundle\Tests\Functional\Bundle\TestBundle\TestBundle; + +return [ + new FrameworkBundle(), + new TestBundle(), +]; diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/TypeInfo/config.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/TypeInfo/config.yml new file mode 100644 index 0000000000000..35c7bb4c46c09 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/TypeInfo/config.yml @@ -0,0 +1,11 @@ +imports: + - { resource: ../config/default.yml } + +framework: + http_method_override: false + type_info: true + +services: + type_info.resolver.alias: + alias: type_info.resolver + public: true diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index 9a50605a21071..766bc80641e09 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -64,6 +64,7 @@ "symfony/string": "^6.4|^7.0", "symfony/translation": "^6.4|^7.0", "symfony/twig-bundle": "^6.4|^7.0", + "symfony/type-info": "^7.1", "symfony/validator": "^6.4|^7.0", "symfony/workflow": "^6.4|^7.0", "symfony/yaml": "^6.4|^7.0", diff --git a/src/Symfony/Component/TypeInfo/.gitattributes b/src/Symfony/Component/TypeInfo/.gitattributes new file mode 100644 index 0000000000000..84c7add058fb5 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/.gitattributes @@ -0,0 +1,4 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/Symfony/Component/TypeInfo/.gitignore b/src/Symfony/Component/TypeInfo/.gitignore new file mode 100644 index 0000000000000..c49a5d8df5c65 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +phpunit.xml diff --git a/src/Symfony/Component/TypeInfo/CHANGELOG.md b/src/Symfony/Component/TypeInfo/CHANGELOG.md new file mode 100644 index 0000000000000..5f941ae21a99a --- /dev/null +++ b/src/Symfony/Component/TypeInfo/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +7.1 +--- + + * Add the component diff --git a/src/Symfony/Component/TypeInfo/Exception/ExceptionInterface.php b/src/Symfony/Component/TypeInfo/Exception/ExceptionInterface.php new file mode 100644 index 0000000000000..fee0c3bd94978 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Exception/ExceptionInterface.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo\Exception; + +/** + * @author Mathias Arlaud + * @author Baptiste Leduc + */ +interface ExceptionInterface extends \Throwable +{ +} diff --git a/src/Symfony/Component/TypeInfo/Exception/InvalidArgumentException.php b/src/Symfony/Component/TypeInfo/Exception/InvalidArgumentException.php new file mode 100644 index 0000000000000..8baae82917683 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Exception/InvalidArgumentException.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo\Exception; + +/** + * @author Mathias Arlaud + * @author Baptiste Leduc + */ +class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/TypeInfo/Exception/LogicException.php b/src/Symfony/Component/TypeInfo/Exception/LogicException.php new file mode 100644 index 0000000000000..06be3c9eb1653 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Exception/LogicException.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo\Exception; + +/** + * @author Mathias Arlaud + * @author Baptiste Leduc + */ +class LogicException extends \LogicException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/TypeInfo/Exception/RuntimeException.php b/src/Symfony/Component/TypeInfo/Exception/RuntimeException.php new file mode 100644 index 0000000000000..143e18ef4ace0 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Exception/RuntimeException.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo\Exception; + +/** + * @author Mathias Arlaud + * @author Baptiste Leduc + */ +class RuntimeException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/TypeInfo/Exception/UnsupportedException.php b/src/Symfony/Component/TypeInfo/Exception/UnsupportedException.php new file mode 100644 index 0000000000000..2c2e45f091a4b --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Exception/UnsupportedException.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo\Exception; + +/** + * @author Mathias Arlaud + * @author Baptiste Leduc + */ +class UnsupportedException extends \LogicException implements ExceptionInterface +{ + public function __construct( + string $message, + public readonly mixed $subject, + int $code = 0, + \Throwable $previous = null, + ) { + parent::__construct($message, $code, $previous); + } +} diff --git a/src/Symfony/Component/TypeInfo/LICENSE b/src/Symfony/Component/TypeInfo/LICENSE new file mode 100644 index 0000000000000..e374a5c8339d3 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2024-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Symfony/Component/TypeInfo/README.md b/src/Symfony/Component/TypeInfo/README.md new file mode 100644 index 0000000000000..6643ed41dd325 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/README.md @@ -0,0 +1,42 @@ +TypeInfo Component +================== + +The TypeInfo component extracts PHP types information. + +Getting Started +--------------- + +```bash +composer require symfony/type-info +composer require phpstan/phpdoc-parser # to support raw string resolving +``` + +```php +resolve(new \ReflectionProperty(Dummy::class, 'id')); // returns an "int" Type instance +$typeResolver->resolve('bool'); // returns a "bool" Type instance + +// Types can be instantiated thanks to static factories +$type = Type::list(Type::nullable(Type::bool())); + +// Type instances have several helper methods +$type->getBaseType() // returns an "array" Type instance +$type->getCollectionKeyType(); // returns an "int" Type instance +$type->getCollectionValueType()->isNullable(); // returns true +``` + +Resources +--------- + + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/TypeInfo/Tests/Fixtures/AbstractDummy.php b/src/Symfony/Component/TypeInfo/Tests/Fixtures/AbstractDummy.php new file mode 100644 index 0000000000000..9dd5a2dc28b54 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/Fixtures/AbstractDummy.php @@ -0,0 +1,7 @@ +id; + } + + public function setId(int $id): void + { + $this->id = $id; + } +} diff --git a/src/Symfony/Component/TypeInfo/Tests/Fixtures/DummyBackedEnum.php b/src/Symfony/Component/TypeInfo/Tests/Fixtures/DummyBackedEnum.php new file mode 100644 index 0000000000000..2348415910314 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/Fixtures/DummyBackedEnum.php @@ -0,0 +1,9 @@ +price : $this->price / 100; + } +} diff --git a/src/Symfony/Component/TypeInfo/Tests/Fixtures/DummyWithUses.php b/src/Symfony/Component/TypeInfo/Tests/Fixtures/DummyWithUses.php new file mode 100644 index 0000000000000..58517a4bd0428 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/Fixtures/DummyWithUses.php @@ -0,0 +1,22 @@ +createdAt = $createdAt; + } + + public function getType(): Type + { + throw new \LogicException('Should not be called.'); + } +} diff --git a/src/Symfony/Component/TypeInfo/Tests/Fixtures/ReflectionExtractableDummy.php b/src/Symfony/Component/TypeInfo/Tests/Fixtures/ReflectionExtractableDummy.php new file mode 100644 index 0000000000000..46979ce822361 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/Fixtures/ReflectionExtractableDummy.php @@ -0,0 +1,79 @@ +builtin; + } + + public function getSelf(): self + { + return $this->self; + } + + public function getStatic(): static + { + return $this; + } + + public function getNullableStatic(): ?static + { + return null; + } + + public function getNothing() + { + return $this->nothing; + } + + public function setBuiltin(int $builtin): void + { + $this->builtin = $builtin; + } + + public function setSelf(self $self): void + { + $this->self = $self; + } + + public function setNothing($nothing): void + { + $this->nothing = $nothing; + } + + public function setOptional(int $optional = null): void + { + } +} diff --git a/src/Symfony/Component/TypeInfo/Tests/Type/BackedEnumTypeTest.php b/src/Symfony/Component/TypeInfo/Tests/Type/BackedEnumTypeTest.php new file mode 100644 index 0000000000000..be3fb65678e3e --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/Type/BackedEnumTypeTest.php @@ -0,0 +1,44 @@ + + * + * 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\Tests\Fixtures\DummyBackedEnum; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\BackedEnumType; +use Symfony\Component\TypeInfo\TypeIdentifier; + +class BackedEnumTypeTest extends TestCase +{ + public function testToString() + { + $this->assertSame(DummyBackedEnum::class, (string) new BackedEnumType(DummyBackedEnum::class, Type::int())); + } + + public function testIsNullable() + { + $this->assertFalse((new BackedEnumType(DummyBackedEnum::class, Type::int()))->isNullable()); + } + + public function testAsNonNullable() + { + $type = new BackedEnumType(DummyBackedEnum::class, Type::int()); + + $this->assertSame($type, $type->asNonNullable()); + } + + public function testIsA() + { + $this->assertFalse((new BackedEnumType(DummyBackedEnum::class, Type::int()))->isA(TypeIdentifier::ARRAY)); + $this->assertTrue((new BackedEnumType(DummyBackedEnum::class, Type::int()))->isA(TypeIdentifier::OBJECT)); + } +} diff --git a/src/Symfony/Component/TypeInfo/Tests/Type/BuiltinTypeTest.php b/src/Symfony/Component/TypeInfo/Tests/Type/BuiltinTypeTest.php new file mode 100644 index 0000000000000..c417e71ec58fa --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/Type/BuiltinTypeTest.php @@ -0,0 +1,65 @@ + + * + * 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\Exception\LogicException; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\TypeIdentifier; + +class BuiltinTypeTest extends TestCase +{ + public function testToString() + { + $this->assertSame('int', (string) new BuiltinType(TypeIdentifier::INT)); + } + + public function testIsNullable() + { + $this->assertFalse((new BuiltinType(TypeIdentifier::INT))->isNullable()); + $this->assertTrue((new BuiltinType(TypeIdentifier::NULL))->isNullable()); + $this->assertTrue((new BuiltinType(TypeIdentifier::MIXED))->isNullable()); + } + + public function testAsNonNullable() + { + $type = new BuiltinType(TypeIdentifier::INT); + + $this->assertSame($type, $type->asNonNullable()); + $this->assertEquals( + Type::union( + new BuiltinType(TypeIdentifier::OBJECT), + new BuiltinType(TypeIdentifier::RESOURCE), + new BuiltinType(TypeIdentifier::ARRAY), + new BuiltinType(TypeIdentifier::STRING), + new BuiltinType(TypeIdentifier::FLOAT), + new BuiltinType(TypeIdentifier::INT), + new BuiltinType(TypeIdentifier::BOOL), + ), + Type::nullable(new BuiltinType(TypeIdentifier::MIXED))->asNonNullable() + ); + } + + public function testCannotTurnNullAsNonNullable() + { + $this->expectException(LogicException::class); + + (new BuiltinType(TypeIdentifier::NULL))->asNonNullable(); + } + + public function testIsA() + { + $this->assertFalse((new BuiltinType(TypeIdentifier::INT))->isA(TypeIdentifier::ARRAY)); + $this->assertTrue((new BuiltinType(TypeIdentifier::INT))->isA(TypeIdentifier::INT)); + } +} diff --git a/src/Symfony/Component/TypeInfo/Tests/Type/CollectionTypeTest.php b/src/Symfony/Component/TypeInfo/Tests/Type/CollectionTypeTest.php new file mode 100644 index 0000000000000..5faee73d95ea4 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/Type/CollectionTypeTest.php @@ -0,0 +1,99 @@ + + * + * 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\Exception\InvalidArgumentException; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\GenericType; +use Symfony\Component\TypeInfo\TypeIdentifier; + +class CollectionTypeTest extends TestCase +{ + public function testCanOnlyConstructListWithIntKeyType() + { + new CollectionType(Type::generic(Type::builtin(TypeIdentifier::ARRAY), Type::int(), Type::bool()), isList: true); + $this->addToAssertionCount(1); + + $this->expectException(InvalidArgumentException::class); + new CollectionType(Type::generic(Type::builtin(TypeIdentifier::ARRAY), Type::string(), Type::bool()), isList: true); + } + + public function testIsList() + { + $type = new CollectionType(Type::generic(Type::builtin(TypeIdentifier::ARRAY), Type::bool())); + $this->assertFalse($type->isList()); + + $type = new CollectionType(Type::generic(Type::builtin(TypeIdentifier::ARRAY), Type::bool()), isList: true); + $this->assertTrue($type->isList()); + } + + public function testGetCollectionKeyType() + { + $type = new CollectionType(Type::builtin(TypeIdentifier::ARRAY)); + $this->assertEquals(Type::union(Type::int(), Type::string()), $type->getCollectionKeyType()); + + $type = new CollectionType(Type::generic(Type::builtin(TypeIdentifier::ARRAY), Type::bool())); + $this->assertEquals(Type::int(), $type->getCollectionKeyType()); + + $type = new CollectionType(Type::generic(Type::builtin(TypeIdentifier::ARRAY), Type::string(), Type::bool())); + $this->assertEquals(Type::string(), $type->getCollectionKeyType()); + } + + public function testGetCollectionValueType() + { + $type = new CollectionType(Type::builtin(TypeIdentifier::ARRAY)); + $this->assertEquals(Type::mixed(), $type->getCollectionValueType()); + + $type = new CollectionType(Type::generic(Type::builtin(TypeIdentifier::ARRAY), Type::bool())); + $this->assertEquals(Type::bool(), $type->getCollectionValueType()); + + $type = new CollectionType(new GenericType(Type::builtin(TypeIdentifier::ARRAY), Type::string(), Type::bool())); + $this->assertEquals(Type::bool(), $type->getCollectionValueType()); + } + + public function testToString() + { + $type = new CollectionType(Type::builtin(TypeIdentifier::ITERABLE)); + $this->assertEquals('iterable', (string) $type); + + $type = new CollectionType(Type::generic(Type::builtin(TypeIdentifier::ARRAY), Type::bool())); + $this->assertEquals('array', (string) $type); + + $type = new CollectionType(new GenericType(Type::builtin(TypeIdentifier::ARRAY), Type::string(), Type::bool())); + $this->assertEquals('array', (string) $type); + } + + public function testIsNullable() + { + $this->assertFalse((new CollectionType(Type::generic(Type::builtin(TypeIdentifier::ARRAY), Type::int())))->isNullable()); + $this->assertTrue((new CollectionType(Type::generic(Type::null(), Type::int())))->isNullable()); + $this->assertTrue((new CollectionType(Type::generic(Type::mixed(), Type::int())))->isNullable()); + } + + public function testAsNonNullable() + { + $type = new CollectionType(Type::builtin(TypeIdentifier::ITERABLE)); + + $this->assertSame($type, $type->asNonNullable()); + } + + public function testIsA() + { + $type = new CollectionType(new GenericType(Type::builtin(TypeIdentifier::ARRAY), Type::string(), Type::bool())); + + $this->assertTrue($type->isA(TypeIdentifier::ARRAY)); + $this->assertFalse($type->isA(TypeIdentifier::STRING)); + $this->assertFalse($type->isA(TypeIdentifier::INT)); + } +} diff --git a/src/Symfony/Component/TypeInfo/Tests/Type/EnumTypeTest.php b/src/Symfony/Component/TypeInfo/Tests/Type/EnumTypeTest.php new file mode 100644 index 0000000000000..fdf7bafb3805a --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/Type/EnumTypeTest.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\Tests\Fixtures\DummyEnum; +use Symfony\Component\TypeInfo\Type\EnumType; +use Symfony\Component\TypeInfo\TypeIdentifier; + +class EnumTypeTest extends TestCase +{ + public function testToString() + { + $this->assertSame(DummyEnum::class, (string) new EnumType(DummyEnum::class)); + } + + public function testIsNullable() + { + $this->assertFalse((new EnumType(DummyEnum::class))->isNullable()); + } + + public function testAsNonNullable() + { + $type = new EnumType(DummyEnum::class); + + $this->assertSame($type, $type->asNonNullable()); + } + + public function testIsA() + { + $this->assertFalse((new EnumType(DummyEnum::class))->isA(TypeIdentifier::ARRAY)); + $this->assertTrue((new EnumType(DummyEnum::class))->isA(TypeIdentifier::OBJECT)); + } +} diff --git a/src/Symfony/Component/TypeInfo/Tests/Type/GenericTypeTest.php b/src/Symfony/Component/TypeInfo/Tests/Type/GenericTypeTest.php new file mode 100644 index 0000000000000..bdaf7e8654834 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/Type/GenericTypeTest.php @@ -0,0 +1,58 @@ + + * + * 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\GenericType; +use Symfony\Component\TypeInfo\TypeIdentifier; + +class GenericTypeTest extends TestCase +{ + public function testToString() + { + $type = new GenericType(Type::builtin(TypeIdentifier::ARRAY), Type::bool()); + $this->assertEquals('array', (string) $type); + + $type = new GenericType(Type::builtin(TypeIdentifier::ARRAY), Type::string(), Type::bool()); + $this->assertEquals('array', (string) $type); + + $type = new GenericType(Type::object(self::class), Type::union(Type::bool(), Type::string()), Type::int(), Type::float()); + $this->assertEquals(sprintf('%s', self::class), (string) $type); + } + + public function testIsNullable() + { + $this->assertFalse((new GenericType(Type::builtin(TypeIdentifier::ARRAY), Type::int()))->isNullable()); + $this->assertTrue((new GenericType(Type::null(), Type::int()))->isNullable()); + $this->assertTrue((new GenericType(Type::mixed(), Type::int()))->isNullable()); + } + + public function testAsNonNullable() + { + $type = new GenericType(Type::builtin(TypeIdentifier::ARRAY), Type::int()); + + $this->assertSame($type, $type->asNonNullable()); + } + + public function testIsA() + { + $type = new GenericType(Type::builtin(TypeIdentifier::ARRAY), Type::string(), Type::bool()); + $this->assertTrue($type->isA(TypeIdentifier::ARRAY)); + $this->assertFalse($type->isA(TypeIdentifier::STRING)); + + $type = new GenericType(Type::object(self::class), Type::union(Type::bool(), Type::string()), Type::int(), Type::float()); + $this->assertTrue($type->isA(TypeIdentifier::OBJECT)); + $this->assertFalse($type->isA(TypeIdentifier::INT)); + $this->assertFalse($type->isA(TypeIdentifier::STRING)); + } +} diff --git a/src/Symfony/Component/TypeInfo/Tests/Type/IntersectionTypeTest.php b/src/Symfony/Component/TypeInfo/Tests/Type/IntersectionTypeTest.php new file mode 100644 index 0000000000000..a6db32f126234 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/Type/IntersectionTypeTest.php @@ -0,0 +1,99 @@ + + * + * 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\Exception\InvalidArgumentException; +use Symfony\Component\TypeInfo\Exception\LogicException; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\Type\IntersectionType; +use Symfony\Component\TypeInfo\TypeIdentifier; + +class IntersectionTypeTest extends TestCase +{ + public function testCannotCreateWithOnlyOneType() + { + $this->expectException(InvalidArgumentException::class); + new IntersectionType(Type::int()); + } + + public function testCannotCreateWithIntersectionTypeParts() + { + $this->expectException(InvalidArgumentException::class); + new IntersectionType(Type::int(), new IntersectionType()); + } + + public function testSortTypesOnCreation() + { + $type = new IntersectionType(Type::int(), Type::string(), Type::bool()); + $this->assertEquals([Type::bool(), Type::int(), Type::string()], $type->getTypes()); + } + + public function testAtLeastOneTypeIs() + { + $type = new IntersectionType(Type::int(), Type::string(), Type::bool()); + + $this->assertTrue($type->atLeastOneTypeIs(fn (Type $t) => 'int' === (string) $t)); + $this->assertFalse($type->atLeastOneTypeIs(fn (Type $t) => 'float' === (string) $t)); + } + + public function testEveryTypeIs() + { + $type = new IntersectionType(Type::int(), Type::string(), Type::bool()); + $this->assertTrue($type->everyTypeIs(fn (Type $t) => $t instanceof BuiltinType)); + + $type = new IntersectionType(Type::int(), Type::string(), Type::template('T')); + $this->assertFalse($type->everyTypeIs(fn (Type $t) => $t instanceof BuiltinType)); + } + + public function testToString() + { + $type = new IntersectionType(Type::int(), Type::string(), Type::float()); + $this->assertSame('float&int&string', (string) $type); + + $type = new IntersectionType(Type::int(), Type::string(), Type::union(Type::float(), Type::bool())); + $this->assertSame('(bool|float)&int&string', (string) $type); + } + + public function testIsNullable() + { + $this->assertFalse((new IntersectionType(Type::int(), Type::string(), Type::float()))->isNullable()); + $this->assertTrue((new IntersectionType(Type::null(), Type::union(Type::int(), Type::mixed())))->isNullable()); + } + + public function testAsNonNullable() + { + $type = new IntersectionType(Type::int(), Type::string(), Type::float()); + + $this->assertSame($type, $type->asNonNullable()); + } + + public function testCannotTurnNullIntersectionAsNonNullable() + { + $this->expectException(LogicException::class); + + $type = (new IntersectionType(Type::null(), Type::mixed()))->asNonNullable(); + } + + public function testIsA() + { + $type = new IntersectionType(Type::int(), Type::string(), Type::float()); + $this->assertFalse($type->isA(TypeIdentifier::ARRAY)); + + $type = new IntersectionType(Type::int(), Type::string(), Type::union(Type::float(), Type::bool())); + $this->assertFalse($type->isA(TypeIdentifier::INT)); + + $type = new IntersectionType(Type::int(), Type::union(Type::int(), Type::int())); + $this->assertTrue($type->isA(TypeIdentifier::INT)); + } +} diff --git a/src/Symfony/Component/TypeInfo/Tests/Type/ObjectTypeTest.php b/src/Symfony/Component/TypeInfo/Tests/Type/ObjectTypeTest.php new file mode 100644 index 0000000000000..c9e3399361a20 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/Type/ObjectTypeTest.php @@ -0,0 +1,35 @@ + + * + * 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\ObjectType; +use Symfony\Component\TypeInfo\TypeIdentifier; + +class ObjectTypeTest extends TestCase +{ + public function testToString() + { + $this->assertSame(self::class, (string) new ObjectType(self::class)); + } + + public function testIsNullable() + { + $this->assertFalse((new ObjectType(self::class))->isNullable()); + } + + public function testIsA() + { + $this->assertFalse((new ObjectType(self::class))->isA(TypeIdentifier::ARRAY)); + $this->assertTrue((new ObjectType(self::class))->isA(TypeIdentifier::OBJECT)); + } +} diff --git a/src/Symfony/Component/TypeInfo/Tests/Type/UnionTypeTest.php b/src/Symfony/Component/TypeInfo/Tests/Type/UnionTypeTest.php new file mode 100644 index 0000000000000..827793ff71c2e --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/Type/UnionTypeTest.php @@ -0,0 +1,117 @@ + + * + * 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\Exception\InvalidArgumentException; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\Type\UnionType; +use Symfony\Component\TypeInfo\TypeIdentifier; + +class UnionTypeTest extends TestCase +{ + public function testCannotCreateWithOnlyOneType() + { + $this->expectException(InvalidArgumentException::class); + new UnionType(Type::int()); + } + + public function testCannotCreateWithUnionTypeParts() + { + $this->expectException(InvalidArgumentException::class); + new UnionType(Type::int(), new UnionType()); + } + + public function testSortTypesOnCreation() + { + $type = new UnionType(Type::int(), Type::string(), Type::bool()); + $this->assertEquals([Type::bool(), Type::int(), Type::string()], $type->getTypes()); + } + + public function testAsNonNullable() + { + $type = new UnionType(Type::int(), Type::string(), Type::bool()); + $this->assertInstanceOf(UnionType::class, $type->asNonNullable()); + $this->assertEquals([Type::bool(), Type::int(), Type::string()], $type->asNonNullable()->getTypes()); + + $type = new UnionType(Type::int(), Type::string(), Type::null()); + $this->assertInstanceOf(UnionType::class, $type->asNonNullable()); + $this->assertEquals([Type::int(), Type::string()], $type->asNonNullable()->getTypes()); + + $type = new UnionType(Type::int(), Type::null()); + $this->assertInstanceOf(BuiltinType::class, $type->asNonNullable()); + $this->assertEquals(Type::int(), $type->asNonNullable()); + + $type = new UnionType(Type::int(), Type::object(\stdClass::class), Type::mixed()); + $this->assertInstanceOf(UnionType::class, $type->asNonNullable()); + $this->assertEquals([ + Type::builtin(TypeIdentifier::ARRAY), + Type::bool(), + Type::float(), + Type::int(), + Type::object(), + Type::resource(), + Type::object(\stdClass::class), + Type::string(), + ], $type->asNonNullable()->getTypes()); + } + + public function testAtLeastOneTypeIs() + { + $type = new UnionType(Type::int(), Type::string(), Type::bool()); + + $this->assertTrue($type->atLeastOneTypeIs(fn (Type $t) => 'int' === (string) $t)); + $this->assertFalse($type->atLeastOneTypeIs(fn (Type $t) => 'float' === (string) $t)); + } + + public function testEveryTypeIs() + { + $type = new UnionType(Type::int(), Type::string(), Type::bool()); + $this->assertTrue($type->everyTypeIs(fn (Type $t) => $t instanceof BuiltinType)); + + $type = new UnionType(Type::int(), Type::string(), Type::template('T')); + $this->assertFalse($type->everyTypeIs(fn (Type $t) => $t instanceof BuiltinType)); + } + + public function testToString() + { + $type = new UnionType(Type::int(), Type::string(), Type::float()); + $this->assertSame('float|int|string', (string) $type); + + $type = new UnionType(Type::int(), Type::string(), Type::intersection(Type::float(), Type::bool())); + $this->assertSame('(bool&float)|int|string', (string) $type); + } + + public function testIsNullable() + { + $this->assertFalse((new UnionType(Type::int(), Type::intersection(Type::float(), Type::int())))->isNullable()); + $this->assertTrue((new UnionType(Type::int(), Type::null()))->isNullable()); + $this->assertTrue((new UnionType(Type::int(), Type::mixed()))->isNullable()); + } + + public function testIsA() + { + $type = new UnionType(Type::int(), Type::string(), Type::float()); + $this->assertFalse($type->isNullable()); + $this->assertFalse($type->isA(TypeIdentifier::ARRAY)); + + $type = new UnionType(Type::int(), Type::string(), Type::intersection(Type::float(), Type::bool())); + $this->assertTrue($type->isA(TypeIdentifier::INT)); + $this->assertTrue($type->isA(TypeIdentifier::STRING)); + $this->assertFalse($type->isA(TypeIdentifier::FLOAT)); + $this->assertFalse($type->isA(TypeIdentifier::BOOL)); + + $type = new UnionType(Type::string(), Type::intersection(Type::int(), Type::int())); + $this->assertTrue($type->isA(TypeIdentifier::INT)); + } +} diff --git a/src/Symfony/Component/TypeInfo/Tests/TypeContext/TypeContextFactoryTest.php b/src/Symfony/Component/TypeInfo/Tests/TypeContext/TypeContextFactoryTest.php new file mode 100644 index 0000000000000..1de82676b0334 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/TypeContext/TypeContextFactoryTest.php @@ -0,0 +1,122 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo\Tests\TypeContext; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\TypeInfo\Tests\Fixtures\AbstractDummy; +use Symfony\Component\TypeInfo\Tests\Fixtures\Dummy; +use Symfony\Component\TypeInfo\Tests\Fixtures\DummyWithTemplates; +use Symfony\Component\TypeInfo\Tests\Fixtures\DummyWithUses; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeContext\TypeContextFactory; +use Symfony\Component\TypeInfo\TypeResolver\StringTypeResolver; + +class TypeContextFactoryTest extends TestCase +{ + private TypeContextFactory $typeContextFactory; + + protected function setUp(): void + { + $this->typeContextFactory = new TypeContextFactory(new StringTypeResolver()); + } + + public function testCollectClassNames() + { + $typeContext = $this->typeContextFactory->createFromClassName(Dummy::class, AbstractDummy::class); + $this->assertSame('Dummy', $typeContext->calledClassName); + $this->assertSame('AbstractDummy', $typeContext->declaringClassName); + + $typeContext = $this->typeContextFactory->createFromReflection(new \ReflectionClass(Dummy::class)); + $this->assertSame('Dummy', $typeContext->calledClassName); + $this->assertSame('Dummy', $typeContext->declaringClassName); + + $typeContext = $this->typeContextFactory->createFromReflection(new \ReflectionProperty(Dummy::class, 'id')); + $this->assertSame('Dummy', $typeContext->calledClassName); + $this->assertSame('Dummy', $typeContext->declaringClassName); + + $typeContext = $this->typeContextFactory->createFromReflection(new \ReflectionMethod(Dummy::class, 'getId')); + $this->assertSame('Dummy', $typeContext->calledClassName); + $this->assertSame('Dummy', $typeContext->declaringClassName); + + $typeContext = $this->typeContextFactory->createFromReflection(new \ReflectionParameter([Dummy::class, 'setId'], 'id')); + $this->assertSame('Dummy', $typeContext->calledClassName); + $this->assertSame('Dummy', $typeContext->declaringClassName); + } + + public function testCollectNamespace() + { + $namespace = 'Symfony\\Component\\TypeInfo\\Tests\\Fixtures'; + + $this->assertSame($namespace, $this->typeContextFactory->createFromClassName(Dummy::class)->namespace); + + $this->assertEquals($namespace, $this->typeContextFactory->createFromReflection(new \ReflectionClass(Dummy::class))->namespace); + $this->assertEquals($namespace, $this->typeContextFactory->createFromReflection(new \ReflectionProperty(Dummy::class, 'id'))->namespace); + $this->assertEquals($namespace, $this->typeContextFactory->createFromReflection(new \ReflectionMethod(Dummy::class, 'getId'))->namespace); + $this->assertEquals($namespace, $this->typeContextFactory->createFromReflection(new \ReflectionParameter([Dummy::class, 'setId'], 'id'))->namespace); + } + + public function testCollectUses() + { + $this->assertSame([], $this->typeContextFactory->createFromClassName(Dummy::class)->uses); + + $uses = [ + 'Type' => Type::class, + \DateTimeInterface::class => '\\'.\DateTimeInterface::class, + 'DateTime' => '\\'.\DateTimeImmutable::class, + ]; + + $this->assertSame($uses, $this->typeContextFactory->createFromClassName(DummyWithUses::class)->uses); + + $this->assertEquals($uses, $this->typeContextFactory->createFromReflection(new \ReflectionClass(DummyWithUses::class))->uses); + $this->assertEquals($uses, $this->typeContextFactory->createFromReflection(new \ReflectionProperty(DummyWithUses::class, 'createdAt'))->uses); + $this->assertEquals($uses, $this->typeContextFactory->createFromReflection(new \ReflectionMethod(DummyWithUses::class, 'setCreatedAt'))->uses); + $this->assertEquals($uses, $this->typeContextFactory->createFromReflection(new \ReflectionParameter([DummyWithUses::class, 'setCreatedAt'], 'createdAt'))->uses); + } + + public function testCollectTemplates() + { + $this->assertEquals([], $this->typeContextFactory->createFromClassName(Dummy::class)->templates); + $this->assertEquals([ + 'T' => Type::union(Type::int(), Type::string()), + 'U' => Type::mixed(), + ], $this->typeContextFactory->createFromClassName(DummyWithTemplates::class)->templates); + + $this->assertEquals([ + 'T' => Type::union(Type::int(), Type::string()), + 'U' => Type::mixed(), + ], $this->typeContextFactory->createFromReflection(new \ReflectionClass(DummyWithTemplates::class))->templates); + + $this->assertEquals([ + 'T' => Type::union(Type::int(), Type::string()), + 'U' => Type::mixed(), + ], $this->typeContextFactory->createFromReflection(new \ReflectionProperty(DummyWithTemplates::class, 'price'))->templates); + + $this->assertEquals([ + 'T' => Type::union(Type::int(), Type::float()), + 'U' => Type::mixed(), + 'V' => Type::mixed(), + ], $this->typeContextFactory->createFromReflection(new \ReflectionMethod(DummyWithTemplates::class, 'getPrice'))->templates); + + $this->assertEquals([ + 'T' => Type::union(Type::int(), Type::float()), + 'U' => Type::mixed(), + 'V' => Type::mixed(), + ], $this->typeContextFactory->createFromReflection(new \ReflectionParameter([DummyWithTemplates::class, 'getPrice'], 'inCents'))->templates); + } + + public function testDoNotCollectTemplatesWhenToStringTypeResolver() + { + $typeContextFactory = new TypeContextFactory(); + + $this->assertEquals([], $typeContextFactory->createFromClassName(DummyWithTemplates::class)->templates); + } +} diff --git a/src/Symfony/Component/TypeInfo/Tests/TypeContext/TypeContextTest.php b/src/Symfony/Component/TypeInfo/Tests/TypeContext/TypeContextTest.php new file mode 100644 index 0000000000000..e12f0c4b7b83b --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/TypeContext/TypeContextTest.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo\Tests\TypeContext; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\TypeInfo\Exception\LogicException; +use Symfony\Component\TypeInfo\Tests\Fixtures\AbstractDummy; +use Symfony\Component\TypeInfo\Tests\Fixtures\Dummy; +use Symfony\Component\TypeInfo\Tests\Fixtures\DummyExtendingStdClass; +use Symfony\Component\TypeInfo\Tests\Fixtures\DummyWithUses; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeContext\TypeContext; +use Symfony\Component\TypeInfo\TypeContext\TypeContextFactory; + +class TypeContextTest extends TestCase +{ + public function testNormalize() + { + $typeContext = (new TypeContextFactory())->createFromClassName(DummyWithUses::class); + + $this->assertSame(DummyWithUses::class, $typeContext->normalize('DummyWithUses')); + $this->assertSame(Type::class, $typeContext->normalize('Type')); + $this->assertSame('\\'.\DateTimeImmutable::class, $typeContext->normalize('DateTime')); + $this->assertSame('Symfony\\Component\\TypeInfo\\Tests\\Fixtures\\unknown', $typeContext->normalize('unknown')); + $this->assertSame('unknown', $typeContext->normalize('\\unknown')); + + $typeContextWithoutNamespace = new TypeContext('Foo', 'Bar'); + $this->assertSame('unknown', $typeContextWithoutNamespace->normalize('unknown')); + } + + public function testGetDeclaringClass() + { + $this->assertSame(Dummy::class, (new TypeContextFactory())->createFromClassName(Dummy::class)->getDeclaringClass()); + $this->assertSame(AbstractDummy::class, (new TypeContextFactory())->createFromClassName(Dummy::class, AbstractDummy::class)->getDeclaringClass()); + } + + public function testGetCalledClass() + { + $this->assertSame(Dummy::class, (new TypeContextFactory())->createFromClassName(Dummy::class)->getCalledClass()); + $this->assertSame(Dummy::class, (new TypeContextFactory())->createFromClassName(Dummy::class, AbstractDummy::class)->getCalledClass()); + } + + public function testGetParentClass() + { + $this->assertSame(AbstractDummy::class, (new TypeContextFactory())->createFromClassName(Dummy::class)->getParentClass()); + $this->assertSame(\stdClass::class, (new TypeContextFactory())->createFromClassName(DummyExtendingStdClass::class)->getParentClass()); + } + + public function testCannotGetParentClassWhenDoNotInherit() + { + $this->expectException(LogicException::class); + (new TypeContextFactory())->createFromClassName(AbstractDummy::class)->getParentClass(); + } +} diff --git a/src/Symfony/Component/TypeInfo/Tests/TypeFactoryTest.php b/src/Symfony/Component/TypeInfo/Tests/TypeFactoryTest.php new file mode 100644 index 0000000000000..1d22d22a9008b --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/TypeFactoryTest.php @@ -0,0 +1,208 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\TypeInfo\Exception\InvalidArgumentException; +use Symfony\Component\TypeInfo\Tests\Fixtures\DummyBackedEnum; +use Symfony\Component\TypeInfo\Tests\Fixtures\DummyEnum; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\BackedEnumType; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\EnumType; +use Symfony\Component\TypeInfo\Type\GenericType; +use Symfony\Component\TypeInfo\Type\IntersectionType; +use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\Type\TemplateType; +use Symfony\Component\TypeInfo\Type\UnionType; +use Symfony\Component\TypeInfo\TypeIdentifier; + +class TypeFactoryTest extends TestCase +{ + public function testCreateBuiltin() + { + $this->assertEquals(new BuiltinType(TypeIdentifier::INT), Type::builtin(TypeIdentifier::INT)); + $this->assertEquals(new BuiltinType(TypeIdentifier::INT), Type::builtin('int')); + $this->assertEquals(new BuiltinType(TypeIdentifier::INT), Type::int()); + $this->assertEquals(new BuiltinType(TypeIdentifier::FLOAT), Type::float()); + $this->assertEquals(new BuiltinType(TypeIdentifier::STRING), Type::string()); + $this->assertEquals(new BuiltinType(TypeIdentifier::BOOL), Type::bool()); + $this->assertEquals(new BuiltinType(TypeIdentifier::RESOURCE), Type::resource()); + $this->assertEquals(new BuiltinType(TypeIdentifier::FALSE), Type::false()); + $this->assertEquals(new BuiltinType(TypeIdentifier::TRUE), Type::true()); + $this->assertEquals(new BuiltinType(TypeIdentifier::CALLABLE), Type::callable()); + $this->assertEquals(new BuiltinType(TypeIdentifier::NULL), Type::null()); + $this->assertEquals(new BuiltinType(TypeIdentifier::MIXED), Type::mixed()); + $this->assertEquals(new BuiltinType(TypeIdentifier::VOID), Type::void()); + $this->assertEquals(new BuiltinType(TypeIdentifier::NEVER), Type::never()); + } + + public function testCreateArray() + { + $this->assertEquals(new CollectionType(new BuiltinType(TypeIdentifier::ARRAY)), Type::array()); + + $this->assertEquals( + new CollectionType(new GenericType( + new BuiltinType(TypeIdentifier::ARRAY), + new UnionType(new BuiltinType(TypeIdentifier::INT), new BuiltinType(TypeIdentifier::STRING)), + new BuiltinType(TypeIdentifier::BOOL), + )), + Type::array(Type::bool()), + ); + + $this->assertEquals( + new CollectionType(new GenericType( + new BuiltinType(TypeIdentifier::ARRAY), + new BuiltinType(TypeIdentifier::STRING), + new BuiltinType(TypeIdentifier::BOOL), + )), + Type::array(Type::bool(), Type::string()), + ); + + $this->assertEquals( + new CollectionType(new GenericType( + new BuiltinType(TypeIdentifier::ARRAY), + new BuiltinType(TypeIdentifier::INT), + new BuiltinType(TypeIdentifier::BOOL), + ), isList: true), + Type::array(Type::bool(), Type::int(), true), + ); + + $this->assertEquals( + new CollectionType(new GenericType( + new BuiltinType(TypeIdentifier::ARRAY), + new BuiltinType(TypeIdentifier::INT), + new BuiltinType(TypeIdentifier::MIXED), + ), isList: true), + Type::list(), + ); + + $this->assertEquals( + new CollectionType(new GenericType( + new BuiltinType(TypeIdentifier::ARRAY), + new BuiltinType(TypeIdentifier::INT), + new BuiltinType(TypeIdentifier::BOOL), + ), isList: true), + Type::list(Type::bool()), + ); + + $this->assertEquals( + new CollectionType(new GenericType( + new BuiltinType(TypeIdentifier::ARRAY), + new BuiltinType(TypeIdentifier::STRING), + new BuiltinType(TypeIdentifier::MIXED), + )), + Type::dict(), + ); + + $this->assertEquals( + new CollectionType(new GenericType( + new BuiltinType(TypeIdentifier::ARRAY), + new BuiltinType(TypeIdentifier::STRING), + new BuiltinType(TypeIdentifier::BOOL), + )), + Type::dict(Type::bool()), + ); + } + + public function testCreateIterable() + { + $this->assertEquals(new CollectionType(new BuiltinType(TypeIdentifier::ITERABLE)), Type::iterable()); + + $this->assertEquals( + new CollectionType(new GenericType( + new BuiltinType(TypeIdentifier::ITERABLE), + new UnionType(new BuiltinType(TypeIdentifier::INT), new BuiltinType(TypeIdentifier::STRING)), + new BuiltinType(TypeIdentifier::BOOL), + )), + Type::iterable(Type::bool()), + ); + + $this->assertEquals( + new CollectionType(new GenericType( + new BuiltinType(TypeIdentifier::ITERABLE), + new BuiltinType(TypeIdentifier::STRING), + new BuiltinType(TypeIdentifier::BOOL), + )), + Type::iterable(Type::bool(), Type::string()), + ); + + $this->assertEquals( + new CollectionType(new GenericType( + new BuiltinType(TypeIdentifier::ITERABLE), + new BuiltinType(TypeIdentifier::INT), + new BuiltinType(TypeIdentifier::BOOL), + ), isList: true), + Type::iterable(Type::bool(), Type::int(), true), + ); + } + + public function testCreateObject() + { + $this->assertEquals(new BuiltinType(TypeIdentifier::OBJECT), Type::object()); + $this->assertEquals(new ObjectType(self::class), Type::object(self::class)); + } + + public function testCreateEnum() + { + $this->assertEquals(new EnumType(DummyEnum::class), Type::enum(DummyEnum::class)); + $this->assertEquals(new BackedEnumType(DummyBackedEnum::class, new BuiltinType(TypeIdentifier::STRING)), Type::enum(DummyBackedEnum::class)); + $this->assertEquals( + new BackedEnumType(DummyBackedEnum::class, new BuiltinType(TypeIdentifier::INT)), + Type::enum(DummyBackedEnum::class, new BuiltinType(TypeIdentifier::INT)), + ); + } + + public function testCreateGeneric() + { + $this->assertEquals( + new GenericType(new ObjectType(self::class), new BuiltinType(TypeIdentifier::INT)), + Type::generic(Type::object(self::class), Type::int()), + ); + } + + public function testCreateTemplate() + { + $this->assertEquals(new TemplateType('T', new BuiltinType(TypeIdentifier::INT)), Type::template('T', Type::int())); + $this->assertEquals(new TemplateType('T', Type::mixed()), Type::template('T')); + } + + public function testCreateUnion() + { + $this->assertEquals(new UnionType(new BuiltinType(TypeIdentifier::INT), new ObjectType(self::class)), Type::union(Type::int(), Type::object(self::class))); + $this->assertEquals(new UnionType(new BuiltinType(TypeIdentifier::INT), new BuiltinType(TypeIdentifier::STRING)), Type::union(Type::int(), Type::string(), Type::int())); + $this->assertEquals(new UnionType(new BuiltinType(TypeIdentifier::INT), new BuiltinType(TypeIdentifier::STRING)), Type::union(Type::int(), Type::union(Type::int(), Type::string()))); + } + + public function testCreateIntersection() + { + $this->assertEquals(new IntersectionType(new BuiltinType(TypeIdentifier::INT), new ObjectType(self::class)), Type::intersection(Type::int(), Type::object(self::class))); + $this->assertEquals(new IntersectionType(new BuiltinType(TypeIdentifier::INT), new BuiltinType(TypeIdentifier::STRING)), Type::intersection(Type::int(), Type::string(), Type::int())); + $this->assertEquals(new IntersectionType(new BuiltinType(TypeIdentifier::INT), new BuiltinType(TypeIdentifier::STRING)), Type::intersection(Type::int(), Type::intersection(Type::int(), Type::string()))); + } + + public function testCreateNullable() + { + $this->assertEquals(new UnionType(new BuiltinType(TypeIdentifier::INT), new BuiltinType(TypeIdentifier::NULL)), Type::nullable(Type::int())); + $this->assertEquals(new UnionType(new BuiltinType(TypeIdentifier::INT), new BuiltinType(TypeIdentifier::NULL)), Type::nullable(Type::nullable(Type::int()))); + + $this->assertEquals( + new UnionType(new BuiltinType(TypeIdentifier::INT), new BuiltinType(TypeIdentifier::STRING), new BuiltinType(TypeIdentifier::NULL)), + Type::nullable(Type::union(Type::int(), Type::string())), + ); + $this->assertEquals( + new UnionType(new BuiltinType(TypeIdentifier::INT), new BuiltinType(TypeIdentifier::STRING), new BuiltinType(TypeIdentifier::NULL)), + Type::nullable(Type::union(Type::int(), Type::string(), Type::null())), + ); + } +} diff --git a/src/Symfony/Component/TypeInfo/Tests/TypeResolver/ReflectionParameterTypeResolverTest.php b/src/Symfony/Component/TypeInfo/Tests/TypeResolver/ReflectionParameterTypeResolverTest.php new file mode 100644 index 0000000000000..333fd7c812492 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/TypeResolver/ReflectionParameterTypeResolverTest.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo\Tests\TypeResolver; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\TypeInfo\Exception\UnsupportedException; +use Symfony\Component\TypeInfo\Tests\Fixtures\ReflectionExtractableDummy; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeContext\TypeContextFactory; +use Symfony\Component\TypeInfo\TypeResolver\ReflectionParameterTypeResolver; +use Symfony\Component\TypeInfo\TypeResolver\ReflectionTypeResolver; + +class ReflectionParameterTypeResolverTest extends TestCase +{ + private ReflectionParameterTypeResolver $resolver; + + protected function setUp(): void + { + $this->resolver = new ReflectionParameterTypeResolver(new ReflectionTypeResolver(), new TypeContextFactory()); + } + + public function testCannotResolveNonReflectionParameter() + { + $this->expectException(UnsupportedException::class); + $this->resolver->resolve(123); + } + + public function testCannotResolveReflectionParameterWithoutType() + { + $this->expectException(UnsupportedException::class); + + $reflectionClass = new \ReflectionClass(ReflectionExtractableDummy::class); + $reflectionParameter = $reflectionClass->getMethod('setNothing')->getParameters()[0]; + + $this->resolver->resolve($reflectionParameter); + } + + public function testResolve() + { + $reflectionClass = new \ReflectionClass(ReflectionExtractableDummy::class); + $reflectionParameter = $reflectionClass->getMethod('setBuiltin')->getParameters()[0]; + + $this->assertEquals(Type::int(), $this->resolver->resolve($reflectionParameter)); + } + + public function testResolveOptionalParameter() + { + $reflectionClass = new \ReflectionClass(ReflectionExtractableDummy::class); + $reflectionParameter = $reflectionClass->getMethod('setOptional')->getParameters()[0]; + + $this->assertEquals(Type::nullable(Type::int()), $this->resolver->resolve($reflectionParameter)); + } + + public function testCreateTypeContextOrUseProvided() + { + $reflectionClass = new \ReflectionClass(ReflectionExtractableDummy::class); + $reflectionParameter = $reflectionClass->getMethod('setSelf')->getParameters()[0]; + + $this->assertEquals(Type::object(ReflectionExtractableDummy::class), $this->resolver->resolve($reflectionParameter)); + + $typeContext = (new TypeContextFactory())->createFromClassName(self::class); + + $this->assertEquals(Type::object(self::class), $this->resolver->resolve($reflectionParameter, $typeContext)); + } +} diff --git a/src/Symfony/Component/TypeInfo/Tests/TypeResolver/ReflectionPropertyTypeResolverTest.php b/src/Symfony/Component/TypeInfo/Tests/TypeResolver/ReflectionPropertyTypeResolverTest.php new file mode 100644 index 0000000000000..6935f818b6f17 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/TypeResolver/ReflectionPropertyTypeResolverTest.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo\Tests\TypeResolver; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\TypeInfo\Exception\UnsupportedException; +use Symfony\Component\TypeInfo\Tests\Fixtures\ReflectionExtractableDummy; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeContext\TypeContextFactory; +use Symfony\Component\TypeInfo\TypeResolver\ReflectionPropertyTypeResolver; +use Symfony\Component\TypeInfo\TypeResolver\ReflectionTypeResolver; + +class ReflectionPropertyTypeResolverTest extends TestCase +{ + private ReflectionPropertyTypeResolver $resolver; + + protected function setUp(): void + { + $this->resolver = new ReflectionPropertyTypeResolver(new ReflectionTypeResolver(), new TypeContextFactory()); + } + + public function testCannotResolveNonReflectionProperty() + { + $this->expectException(UnsupportedException::class); + $this->resolver->resolve(123); + } + + public function testCannotResolveReflectionPropertyWithoutType() + { + $this->expectException(UnsupportedException::class); + + $reflectionClass = new \ReflectionClass(ReflectionExtractableDummy::class); + $reflectionProperty = $reflectionClass->getProperty('nothing'); + + $this->resolver->resolve($reflectionProperty); + } + + public function testResolve() + { + $reflectionClass = new \ReflectionClass(ReflectionExtractableDummy::class); + $reflectionProperty = $reflectionClass->getProperty('builtin'); + + $this->assertEquals(Type::int(), $this->resolver->resolve($reflectionProperty)); + } + + public function testCreateTypeContextOrUseProvided() + { + $reflectionClass = new \ReflectionClass(ReflectionExtractableDummy::class); + $reflectionProperty = $reflectionClass->getProperty('self'); + + $this->assertEquals(Type::object(ReflectionExtractableDummy::class), $this->resolver->resolve($reflectionProperty)); + + $typeContext = (new TypeContextFactory())->createFromClassName(self::class); + + $this->assertEquals(Type::object(self::class), $this->resolver->resolve($reflectionProperty, $typeContext)); + } +} diff --git a/src/Symfony/Component/TypeInfo/Tests/TypeResolver/ReflectionReturnTypeResolverTest.php b/src/Symfony/Component/TypeInfo/Tests/TypeResolver/ReflectionReturnTypeResolverTest.php new file mode 100644 index 0000000000000..56d4fdd821e35 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/TypeResolver/ReflectionReturnTypeResolverTest.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo\Tests\TypeResolver; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\TypeInfo\Exception\UnsupportedException; +use Symfony\Component\TypeInfo\Tests\Fixtures\ReflectionExtractableDummy; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeContext\TypeContextFactory; +use Symfony\Component\TypeInfo\TypeResolver\ReflectionReturnTypeResolver; +use Symfony\Component\TypeInfo\TypeResolver\ReflectionTypeResolver; + +class ReflectionReturnTypeResolverTest extends TestCase +{ + private ReflectionReturnTypeResolver $resolver; + + protected function setUp(): void + { + $this->resolver = new ReflectionReturnTypeResolver(new ReflectionTypeResolver(), new TypeContextFactory()); + } + + public function testCannotResolveNonReflectionFunctionAbstract() + { + $this->expectException(UnsupportedException::class); + $this->resolver->resolve(123); + } + + public function testCannotResolveReflectionFunctionAbstractWithoutType() + { + $this->expectException(UnsupportedException::class); + + $reflectionClass = new \ReflectionClass(ReflectionExtractableDummy::class); + $reflectionFunction = $reflectionClass->getMethod('getNothing'); + + $this->resolver->resolve($reflectionFunction); + } + + public function testResolve() + { + $reflectionClass = new \ReflectionClass(ReflectionExtractableDummy::class); + $reflectionFunction = $reflectionClass->getMethod('getBuiltin'); + + $this->assertEquals(Type::int(), $this->resolver->resolve($reflectionFunction)); + } + + public function testCreateTypeContextOrUseProvided() + { + $reflectionClass = new \ReflectionClass(ReflectionExtractableDummy::class); + $reflectionFunction = $reflectionClass->getMethod('getSelf'); + + $this->assertEquals(Type::object(ReflectionExtractableDummy::class), $this->resolver->resolve($reflectionFunction)); + + $typeContext = (new TypeContextFactory())->createFromClassName(self::class); + + $this->assertEquals(Type::object(self::class), $this->resolver->resolve($reflectionFunction, $typeContext)); + } +} diff --git a/src/Symfony/Component/TypeInfo/Tests/TypeResolver/ReflectionTypeResolverTest.php b/src/Symfony/Component/TypeInfo/Tests/TypeResolver/ReflectionTypeResolverTest.php new file mode 100644 index 0000000000000..89d80ffd03212 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/TypeResolver/ReflectionTypeResolverTest.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo\Tests\TypeResolver; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\TypeInfo\Exception\InvalidArgumentException; +use Symfony\Component\TypeInfo\Exception\UnsupportedException; +use Symfony\Component\TypeInfo\Tests\Fixtures\AbstractDummy; +use Symfony\Component\TypeInfo\Tests\Fixtures\Dummy; +use Symfony\Component\TypeInfo\Tests\Fixtures\DummyBackedEnum; +use Symfony\Component\TypeInfo\Tests\Fixtures\DummyEnum; +use Symfony\Component\TypeInfo\Tests\Fixtures\ReflectionExtractableDummy; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeContext\TypeContext; +use Symfony\Component\TypeInfo\TypeContext\TypeContextFactory; +use Symfony\Component\TypeInfo\TypeResolver\ReflectionTypeResolver; + +class ReflectionTypeResolverTest extends TestCase +{ + private ReflectionTypeResolver $resolver; + + protected function setUp(): void + { + $this->resolver = new ReflectionTypeResolver(); + } + + /** + * @dataProvider resolveDataProvider + */ + public function testResolve(Type $expectedType, \ReflectionType $reflection, TypeContext $typeContext = null) + { + $this->assertEquals($expectedType, $this->resolver->resolve($reflection, $typeContext)); + } + + /** + * @return iterable + */ + public function resolveDataProvider(): iterable + { + $typeContext = (new TypeContextFactory())->createFromClassName(ReflectionExtractableDummy::class); + $reflection = new \ReflectionClass(ReflectionExtractableDummy::class); + + yield [Type::int(), $reflection->getProperty('builtin')->getType()]; + yield [Type::nullable(Type::int()), $reflection->getProperty('nullableBuiltin')->getType()]; + yield [Type::array(), $reflection->getProperty('array')->getType()]; + yield [Type::nullable(Type::array()), $reflection->getProperty('nullableArray')->getType()]; + yield [Type::iterable(), $reflection->getProperty('iterable')->getType()]; + yield [Type::nullable(Type::iterable()), $reflection->getProperty('nullableIterable')->getType()]; + yield [Type::object(Dummy::class), $reflection->getProperty('class')->getType()]; + yield [Type::nullable(Type::object(Dummy::class)), $reflection->getProperty('nullableClass')->getType()]; + yield [Type::object(ReflectionExtractableDummy::class), $reflection->getProperty('self')->getType(), $typeContext]; + yield [Type::nullable(Type::object(ReflectionExtractableDummy::class)), $reflection->getProperty('nullableSelf')->getType(), $typeContext]; + yield [Type::object(ReflectionExtractableDummy::class), $reflection->getMethod('getStatic')->getReturnType(), $typeContext]; + yield [Type::nullable(Type::object(ReflectionExtractableDummy::class)), $reflection->getMethod('getNullableStatic')->getReturnType(), $typeContext]; + yield [Type::object(AbstractDummy::class), $reflection->getProperty('parent')->getType(), $typeContext]; + yield [Type::nullable(Type::object(AbstractDummy::class)), $reflection->getProperty('nullableParent')->getType(), $typeContext]; + yield [Type::enum(DummyEnum::class), $reflection->getProperty('enum')->getType()]; + yield [Type::nullable(Type::enum(DummyEnum::class)), $reflection->getProperty('nullableEnum')->getType()]; + yield [Type::enum(DummyBackedEnum::class), $reflection->getProperty('backedEnum')->getType()]; + yield [Type::nullable(Type::enum(DummyBackedEnum::class)), $reflection->getProperty('nullableBackedEnum')->getType()]; + yield [Type::union(Type::int(), Type::string()), $reflection->getProperty('union')->getType()]; + yield [Type::intersection(Type::object(\Traversable::class), Type::object(\Stringable::class)), $reflection->getProperty('intersection')->getType()]; + } + + public function testCannotResolveNonProperReflectionType() + { + $this->expectException(UnsupportedException::class); + $this->resolver->resolve(new \ReflectionClass(self::class)); + } + + /** + * @dataProvider classKeywordsTypesDataProvider + */ + public function testCannotResolveClassKeywordsWithoutTypeContext(\ReflectionType $reflection) + { + $this->expectException(InvalidArgumentException::class); + $this->resolver->resolve($reflection); + } + + /** + * @return iterable + */ + public function classKeywordsTypesDataProvider(): iterable + { + $reflection = new \ReflectionClass(ReflectionExtractableDummy::class); + + yield [$reflection->getProperty('self')->getType()]; + yield [$reflection->getMethod('getStatic')->getReturnType()]; + yield [$reflection->getProperty('parent')->getType()]; + } +} diff --git a/src/Symfony/Component/TypeInfo/Tests/TypeResolver/StringTypeResolverTest.php b/src/Symfony/Component/TypeInfo/Tests/TypeResolver/StringTypeResolverTest.php new file mode 100644 index 0000000000000..f348baed5f6df --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/TypeResolver/StringTypeResolverTest.php @@ -0,0 +1,190 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo\Tests\TypeResolver; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\TypeInfo\Exception\InvalidArgumentException; +use Symfony\Component\TypeInfo\Exception\UnsupportedException; +use Symfony\Component\TypeInfo\Tests\Fixtures\AbstractDummy; +use Symfony\Component\TypeInfo\Tests\Fixtures\Dummy; +use Symfony\Component\TypeInfo\Tests\Fixtures\DummyWithTemplates; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeContext\TypeContext; +use Symfony\Component\TypeInfo\TypeContext\TypeContextFactory; +use Symfony\Component\TypeInfo\TypeResolver\StringTypeResolver; + +class StringTypeResolverTest extends TestCase +{ + private StringTypeResolver $resolver; + + protected function setUp(): void + { + $this->resolver = new StringTypeResolver(); + } + + /** + * @dataProvider resolveDataProvider + */ + public function testResolve(Type $expectedType, string $string, TypeContext $typeContext = null) + { + $this->assertEquals($expectedType, $this->resolver->resolve($string, $typeContext)); + } + + /** + * @return iterable + */ + public function resolveDataProvider(): iterable + { + $typeContextFactory = new TypeContextFactory(new StringTypeResolver()); + + // callable + yield [Type::callable(), 'callable(string, int): mixed']; + + // array + yield [Type::array(Type::bool()), 'bool[]']; + + // array shape + yield [Type::array(), 'array{0: true, 1: false}']; + + // object shape + yield [Type::object(), 'object{foo: true, bar: false}']; + + // this + yield [Type::object(Dummy::class), '$this', $typeContextFactory->createFromClassName(Dummy::class, AbstractDummy::class)]; + + // const + yield [Type::array(), 'array[1, 2, 3]']; + yield [Type::false(), 'false']; + yield [Type::float(), '1.23']; + yield [Type::int(), '1']; + yield [Type::null(), 'null']; + yield [Type::string(), '"string"']; + yield [Type::true(), 'true']; + + // identifiers + yield [Type::bool(), 'bool']; + yield [Type::bool(), 'boolean']; + yield [Type::true(), 'true']; + 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']; + yield [Type::string(), 'class-string']; + yield [Type::string(), 'trait-string']; + yield [Type::string(), 'interface-string']; + yield [Type::string(), 'callable-string']; + yield [Type::string(), 'numeric-string']; + yield [Type::string(), 'lowercase-string']; + yield [Type::string(), 'non-empty-lowercase-string']; + yield [Type::string(), 'non-empty-string']; + yield [Type::string(), 'non-falsy-string']; + yield [Type::string(), 'truthy-string']; + yield [Type::string(), 'literal-string']; + yield [Type::string(), 'html-escaped-string']; + yield [Type::resource(), 'resource']; + yield [Type::object(), 'object']; + yield [Type::callable(), 'callable']; + yield [Type::array(), 'array']; + yield [Type::array(), 'non-empty-array']; + yield [Type::list(), 'list']; + yield [Type::list(), 'non-empty-list']; + yield [Type::iterable(), 'iterable']; + yield [Type::mixed(), 'mixed']; + yield [Type::null(), 'null']; + yield [Type::void(), 'void']; + yield [Type::never(), 'never']; + yield [Type::never(), 'never-return']; + yield [Type::never(), 'never-returns']; + yield [Type::never(), 'no-return']; + yield [Type::union(Type::int(), Type::string()), 'array-key']; + yield [Type::union(Type::int(), Type::float(), Type::string(), Type::bool()), 'scalar']; + yield [Type::union(Type::int(), Type::float()), 'number']; + yield [Type::union(Type::int(), Type::float(), Type::string()), 'numeric']; + yield [Type::object(AbstractDummy::class), 'self', $typeContextFactory->createFromClassName(Dummy::class, AbstractDummy::class)]; + yield [Type::object(Dummy::class), 'static', $typeContextFactory->createFromClassName(Dummy::class, AbstractDummy::class)]; + yield [Type::object(AbstractDummy::class), 'parent', $typeContextFactory->createFromClassName(Dummy::class)]; + yield [Type::object(Dummy::class), 'Dummy', $typeContextFactory->createFromClassName(Dummy::class)]; + 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'))]; + + // nullable + yield [Type::nullable(Type::int()), '?int']; + + // generic + yield [Type::generic(Type::object(), Type::string(), Type::bool()), 'object']; + yield [Type::generic(Type::object(), Type::generic(Type::string(), Type::bool())), 'object>']; + yield [Type::int(), 'int<0, 100>']; + + // union + yield [Type::union(Type::int(), Type::string()), 'int|string']; + + // intersection + yield [Type::intersection(Type::int(), Type::string()), 'int&string']; + + // DNF + yield [Type::union(Type::int(), Type::intersection(Type::string(), Type::bool())), 'int|(string&bool)']; + + // collection objects + yield [Type::collection(Type::object(\Traversable::class)), \Traversable::class]; + yield [Type::collection(Type::object(\Traversable::class), Type::string()), \Traversable::class.'']; + yield [Type::collection(Type::object(\Traversable::class), Type::bool(), Type::string()), \Traversable::class.'']; + yield [Type::collection(Type::object(\Iterator::class)), \Iterator::class]; + yield [Type::collection(Type::object(\Iterator::class), Type::string()), \Iterator::class.'']; + yield [Type::collection(Type::object(\Iterator::class), Type::bool(), Type::string()), \Iterator::class.'']; + yield [Type::collection(Type::object(\IteratorAggregate::class)), \IteratorAggregate::class]; + yield [Type::collection(Type::object(\IteratorAggregate::class), Type::string()), \IteratorAggregate::class.'']; + yield [Type::collection(Type::object(\IteratorAggregate::class), Type::bool(), Type::string()), \IteratorAggregate::class.'']; + } + + public function testCannotResolveNonStringType() + { + $this->expectException(UnsupportedException::class); + $this->resolver->resolve(123); + } + + public function testCannotResolveThisWithoutTypeContext() + { + $this->expectException(InvalidArgumentException::class); + $this->resolver->resolve('$this'); + } + + public function testCannotResolveSelfWithoutTypeContext() + { + $this->expectException(InvalidArgumentException::class); + $this->resolver->resolve('self'); + } + + public function testCannotResolveStaticWithoutTypeContext() + { + $this->expectException(InvalidArgumentException::class); + $this->resolver->resolve('static'); + } + + public function testCannotResolveParentWithoutTypeContext() + { + $this->expectException(InvalidArgumentException::class); + $this->resolver->resolve('parent'); + } + + public function testCannotUnknownIdentifier() + { + $this->expectException(UnsupportedException::class); + $this->resolver->resolve('unknown'); + } +} diff --git a/src/Symfony/Component/TypeInfo/Tests/TypeResolver/TypeResolverTest.php b/src/Symfony/Component/TypeInfo/Tests/TypeResolver/TypeResolverTest.php new file mode 100644 index 0000000000000..3b778ab71c88d --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/TypeResolver/TypeResolverTest.php @@ -0,0 +1,92 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo\Tests\TypeResolver; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\DependencyInjection\ServiceLocator; +use Symfony\Component\TypeInfo\Exception\UnsupportedException; +use Symfony\Component\TypeInfo\Tests\Fixtures\Dummy; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeResolver\TypeResolver; +use Symfony\Component\TypeInfo\TypeResolver\TypeResolverInterface; + +class TypeResolverTest extends TestCase +{ + public function testResolve() + { + $resolver = TypeResolver::create(); + + $this->assertEquals(Type::bool(), $resolver->resolve('bool')); + $this->assertEquals(Type::int(), $resolver->resolve((new \ReflectionProperty(Dummy::class, 'id'))->getType())); + $this->assertEquals(Type::int(), $resolver->resolve((new \ReflectionMethod(Dummy::class, 'setId'))->getParameters()[0])); + $this->assertEquals(Type::int(), $resolver->resolve(new \ReflectionProperty(Dummy::class, 'id'))); + $this->assertEquals(Type::void(), $resolver->resolve(new \ReflectionMethod(Dummy::class, 'setId'))); + $this->assertEquals(Type::string(), $resolver->resolve(new \ReflectionFunction(strtoupper(...)))); + } + + public function testCannotFindResolver() + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('Cannot find any resolver for "int" type.'); + + $resolver = new TypeResolver(new ServiceLocator([])); + $resolver->resolve(1); + } + + public function testUseProperResolver() + { + $stringResolver = $this->createMock(TypeResolverInterface::class); + $stringResolver->method('resolve')->willReturn(Type::template('STRING')); + + $reflectionTypeResolver = $this->createMock(TypeResolverInterface::class); + $reflectionTypeResolver->method('resolve')->willReturn(Type::template('REFLECTION_TYPE')); + + $reflectionParameterResolver = $this->createMock(TypeResolverInterface::class); + $reflectionParameterResolver->method('resolve')->willReturn(Type::template('REFLECTION_PARAMETER')); + + $reflectionPropertyResolver = $this->createMock(TypeResolverInterface::class); + $reflectionPropertyResolver->method('resolve')->willReturn(Type::template('REFLECTION_PROPERTY')); + + $reflectionReturnTypeResolver = $this->createMock(TypeResolverInterface::class); + $reflectionReturnTypeResolver->method('resolve')->willReturn(Type::template('REFLECTION_RETURN_TYPE')); + + $resolver = new TypeResolver(new ServiceLocator([ + 'string' => fn () => $stringResolver, + \ReflectionType::class => fn () => $reflectionTypeResolver, + \ReflectionParameter::class => fn () => $reflectionParameterResolver, + \ReflectionProperty::class => fn () => $reflectionPropertyResolver, + \ReflectionFunctionAbstract::class => fn () => $reflectionReturnTypeResolver, + ])); + + $this->assertEquals(Type::template('STRING'), $resolver->resolve('foo')); + $this->assertEquals( + Type::template('REFLECTION_TYPE'), + $resolver->resolve((new \ReflectionProperty(Dummy::class, 'id'))->getType()), + ); + $this->assertEquals( + Type::template('REFLECTION_PARAMETER'), + $resolver->resolve((new \ReflectionMethod(Dummy::class, 'setId'))->getParameters()[0]), + ); + $this->assertEquals( + Type::template('REFLECTION_PROPERTY'), + $resolver->resolve(new \ReflectionProperty(Dummy::class, 'id')), + ); + $this->assertEquals( + Type::template('REFLECTION_RETURN_TYPE'), + $resolver->resolve(new \ReflectionMethod(Dummy::class, 'setId')), + ); + $this->assertEquals( + Type::template('REFLECTION_RETURN_TYPE'), + $resolver->resolve(new \ReflectionFunction(strtoupper(...))), + ); + } +} diff --git a/src/Symfony/Component/TypeInfo/Tests/TypeTest.php b/src/Symfony/Component/TypeInfo/Tests/TypeTest.php new file mode 100644 index 0000000000000..2f0ad858ed3e9 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/TypeTest.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\TypeInfo\Exception\LogicException; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeIdentifier; + +class TypeTest extends TestCase +{ + public function testIs() + { + $isInt = fn (Type $t) => TypeIdentifier::INT === $t->getBaseType()->getTypeIdentifier(); + + $this->assertTrue(Type::int()->is($isInt)); + $this->assertTrue(Type::union(Type::string(), Type::int())->is($isInt)); + $this->assertTrue(Type::generic(Type::int(), Type::string())->is($isInt)); + + $this->assertFalse(Type::string()->is($isInt)); + $this->assertFalse(Type::union(Type::string(), Type::float())->is($isInt)); + $this->assertFalse(Type::generic(Type::string(), Type::int())->is($isInt)); + } + + public function testIsA() + { + $this->assertTrue(Type::int()->isA(TypeIdentifier::INT)); + $this->assertTrue(Type::union(Type::string(), Type::int())->isA(TypeIdentifier::INT)); + $this->assertTrue(Type::generic(Type::int(), Type::string())->isA(TypeIdentifier::INT)); + + $this->assertFalse(Type::string()->isA(TypeIdentifier::INT)); + $this->assertFalse(Type::union(Type::string(), Type::float())->isA(TypeIdentifier::INT)); + $this->assertFalse(Type::generic(Type::string(), Type::int())->isA(TypeIdentifier::INT)); + } + + public function testIsNullable() + { + $this->assertTrue(Type::null()->isNullable()); + $this->assertTrue(Type::mixed()->isNullable()); + $this->assertTrue(Type::nullable(Type::int())->isNullable()); + $this->assertTrue(Type::union(Type::int(), Type::null())->isNullable()); + $this->assertTrue(Type::union(Type::int(), Type::mixed())->isNullable()); + $this->assertTrue(Type::generic(Type::null(), Type::string())->isNullable()); + + $this->assertFalse(Type::int()->isNullable()); + $this->assertFalse(Type::union(Type::int(), Type::string())->isNullable()); + $this->assertFalse(Type::generic(Type::int(), Type::nullable(Type::string()))->isNullable()); + $this->assertFalse(Type::generic(Type::int(), Type::mixed())->isNullable()); + } + + public function testGetBaseType() + { + $this->assertEquals(Type::string(), Type::string()->getBaseType()); + $this->assertEquals(Type::object(self::class), Type::object(self::class)->getBaseType()); + $this->assertEquals(Type::object(), Type::generic(Type::object(), Type::int())->getBaseType()); + $this->assertEquals(Type::builtin(TypeIdentifier::ARRAY), Type::list()->getBaseType()); + $this->assertEquals(Type::int(), Type::collection(Type::generic(Type::int(), Type::string()))->getBaseType()); + } + + public function testCannotGetBaseTypeOnCompoundType() + { + $this->expectException(LogicException::class); + Type::union(Type::int(), Type::string())->getBaseType(); + } +} diff --git a/src/Symfony/Component/TypeInfo/Type.php b/src/Symfony/Component/TypeInfo/Type.php new file mode 100644 index 0000000000000..5a81f7e2ecc29 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Type.php @@ -0,0 +1,89 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo; + +use Symfony\Component\TypeInfo\Exception\LogicException; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\GenericType; +use Symfony\Component\TypeInfo\Type\IntersectionType; +use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\Type\UnionType; + +/** + * @author Mathias Arlaud + * @author Baptiste Leduc + */ +abstract class Type implements \Stringable +{ + use TypeFactoryTrait; + + public function getBaseType(): BuiltinType|ObjectType + { + if ($this instanceof UnionType || $this instanceof IntersectionType) { + throw new LogicException(sprintf('Cannot get base type on "%s" compound type.', (string) $this)); + } + + $baseType = $this; + + if ($baseType instanceof CollectionType) { + $baseType = $baseType->getType(); + } + + if ($baseType instanceof GenericType) { + $baseType = $baseType->getType(); + } + + return $baseType; + } + + /** + * @param callable(Type): bool $callable + */ + public function is(callable $callable): bool + { + return match(true) { + $this instanceof UnionType => $this->atLeastOneTypeIs($callable), + $this instanceof IntersectionType => $this->everyTypeIs($callable), + default => $callable($this), + }; + } + + public function isA(TypeIdentifier $typeIdentifier): bool + { + return $this->testIdentifier(fn (TypeIdentifier $i): bool => $typeIdentifier === $i); + } + + public function isNullable(): bool + { + return $this->testIdentifier(fn (TypeIdentifier $i): bool => TypeIdentifier::NULL === $i || TypeIdentifier::MIXED === $i); + } + + abstract public function asNonNullable(): self; + + /** + * @param callable(TypeIdentifier): bool $test + */ + private function testIdentifier(callable $test): bool + { + $callable = function (self $t) use ($test, &$callable): bool { + // unwrap compound type to forward type identifier check + if ($t instanceof UnionType || $t instanceof IntersectionType) { + return $t->is($callable); + } + + return $test($t->getBaseType()->getTypeIdentifier()); + }; + + return $this->is($callable); + } +} diff --git a/src/Symfony/Component/TypeInfo/Type/BackedEnumType.php b/src/Symfony/Component/TypeInfo/Type/BackedEnumType.php new file mode 100644 index 0000000000000..c83017cedf38c --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Type/BackedEnumType.php @@ -0,0 +1,45 @@ + + * + * 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; + +/** + * @author Mathias Arlaud + * @author Baptiste Leduc + * + * @template T of class-string<\BackedEnum> + * @template U of BuiltinType|BuiltinType + * + * @extends EnumType + */ +final class BackedEnumType extends EnumType +{ + /** + * @param T $className + * @param U $backingType + */ + public function __construct( + string $className, + private readonly BuiltinType $backingType, + ) { + parent::__construct($className); + } + + /** + * @return U + */ + public function getBackingType(): BuiltinType + { + return $this->backingType; + } +} diff --git a/src/Symfony/Component/TypeInfo/Type/BuiltinType.php b/src/Symfony/Component/TypeInfo/Type/BuiltinType.php new file mode 100644 index 0000000000000..9a8cc13e25837 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Type/BuiltinType.php @@ -0,0 +1,72 @@ + + * + * 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\Exception\LogicException; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeIdentifier; + +/** + * @author Mathias Arlaud + * @author Baptiste Leduc + * + * @template T of TypeIdentifier + */ +final class BuiltinType extends Type +{ + /** + * @param T $typeIdentifier + */ + public function __construct( + private readonly TypeIdentifier $typeIdentifier, + ) { + } + + /** + * @return T + */ + public function getTypeIdentifier(): TypeIdentifier + { + return $this->typeIdentifier; + } + + /** + * @return self|UnionType|BuiltinType|BuiltinType|BuiltinType|BuiltinType|BuiltinType|BuiltinType> + */ + public function asNonNullable(): self|UnionType + { + if (TypeIdentifier::NULL === $this->typeIdentifier) { + throw new LogicException('"null" cannot be turned as non nullable.'); + } + + // "mixed" is an alias of "object|resource|array|string|float|int|bool|null" + // therefore, its non-nullable version is "object|resource|array|string|float|int|bool" + if (TypeIdentifier::MIXED === $this->typeIdentifier) { + return new UnionType( + new self(TypeIdentifier::OBJECT), + new self(TypeIdentifier::RESOURCE), + new self(TypeIdentifier::ARRAY), + new self(TypeIdentifier::STRING), + new self(TypeIdentifier::FLOAT), + new self(TypeIdentifier::INT), + new self(TypeIdentifier::BOOL), + ); + } + + return $this; + } + + public function __toString(): string + { + return $this->typeIdentifier->value; + } +} diff --git a/src/Symfony/Component/TypeInfo/Type/CollectionType.php b/src/Symfony/Component/TypeInfo/Type/CollectionType.php new file mode 100644 index 0000000000000..6b99d1325eaab --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Type/CollectionType.php @@ -0,0 +1,108 @@ + + * + * 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\Exception\InvalidArgumentException; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeIdentifier; + +/** + * Represents a key/value collection type. + * + * It proxies every method to the main type and adds methods related to key and value types. + * + * @author Mathias Arlaud + * @author Baptiste Leduc + * + * @template T of BuiltinType|BuiltinType|ObjectType|GenericType + */ +final class CollectionType extends Type +{ + /** + * @param T $type + */ + public function __construct( + private readonly BuiltinType|ObjectType|GenericType $type, + private readonly bool $isList = false, + ) { + if ($this->isList()) { + $keyType = $this->getCollectionKeyType(); + + if (!$keyType instanceof BuiltinType || TypeIdentifier::INT !== $keyType->getTypeIdentifier()) { + throw new InvalidArgumentException(sprintf('"%s" is not a valid list key type.', (string) $keyType)); + } + } + } + + /** + * @return T + */ + public function getType(): BuiltinType|ObjectType|GenericType + { + return $this->type; + } + + public function isList(): bool + { + return $this->isList; + } + + public function asNonNullable(): self + { + return $this; + } + + public function getCollectionKeyType(): Type + { + $defaultCollectionKeyType = self::union(self::int(), self::string()); + + if ($this->type instanceof GenericType) { + return match (\count($this->type->getVariableTypes())) { + 2 => $this->type->getVariableTypes()[0], + 1 => self::int(), + default => $defaultCollectionKeyType, + }; + } + + return $defaultCollectionKeyType; + } + + public function getCollectionValueType(): Type + { + $defaultCollectionValueType = self::mixed(); + + if ($this->type instanceof GenericType) { + return match (\count($this->type->getVariableTypes())) { + 2 => $this->type->getVariableTypes()[1], + 1 => $this->type->getVariableTypes()[0], + default => $defaultCollectionValueType, + }; + } + + return $defaultCollectionValueType; + } + + public function __toString(): string + { + return (string) $this->type; + } + + /** + * Proxies all method calls to the original type. + * + * @param list $arguments + */ + public function __call(string $method, array $arguments): mixed + { + return $this->type->{$method}(...$arguments); + } +} diff --git a/src/Symfony/Component/TypeInfo/Type/CompositeTypeTrait.php b/src/Symfony/Component/TypeInfo/Type/CompositeTypeTrait.php new file mode 100644 index 0000000000000..9da43daab924a --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Type/CompositeTypeTrait.php @@ -0,0 +1,86 @@ + + * + * 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\Exception\InvalidArgumentException; +use Symfony\Component\TypeInfo\Type; + +/** + * @author Mathias Arlaud + * @author Baptiste Leduc + * + * @internal + * + * @template T of Type + */ +trait CompositeTypeTrait +{ + /** + * @var list + */ + private readonly array $types; + + /** + * @param list $types + */ + public function __construct(Type ...$types) + { + if (\count($types) < 2) { + throw new InvalidArgumentException(sprintf('"%s" expects at least 2 types.', self::class)); + } + + foreach ($types as $t) { + if ($t instanceof self) { + throw new InvalidArgumentException(sprintf('Cannot set "%s" as a "%1$s" part.', self::class)); + } + } + + usort($types, fn (Type $a, Type $b): int => (string) $a <=> (string) $b); + $this->types = array_values(array_unique($types)); + } + + /** + * @return list + */ + public function getTypes(): array + { + return $this->types; + } + + /** + * @param callable(T): bool $callable + */ + public function atLeastOneTypeIs(callable $callable): bool + { + foreach ($this->types as $t) { + if ($callable($t)) { + return true; + } + } + + return false; + } + + /** + * @param callable(T): bool $callable + */ + public function everyTypeIs(callable $callable): bool + { + foreach ($this->types as $t) { + if (!$callable($t)) { + return false; + } + } + + return true; + } +} diff --git a/src/Symfony/Component/TypeInfo/Type/EnumType.php b/src/Symfony/Component/TypeInfo/Type/EnumType.php new file mode 100644 index 0000000000000..95665921d1590 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Type/EnumType.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo\Type; + +/** + * @author Mathias Arlaud + * @author Baptiste Leduc + * + * @template T of class-string<\UnitEnum> + * + * @extends ObjectType + */ +class EnumType extends ObjectType +{ +} diff --git a/src/Symfony/Component/TypeInfo/Type/GenericType.php b/src/Symfony/Component/TypeInfo/Type/GenericType.php new file mode 100644 index 0000000000000..dc6a845278da8 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Type/GenericType.php @@ -0,0 +1,88 @@ + + * + * 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\Type; +use Symfony\Component\TypeInfo\TypeIdentifier; + +/** + * Represents a generic type, which is a type that holds variable parts. + * + * It proxies every method to the main type and adds methods related to variable types. + * + * @author Mathias Arlaud + * @author Baptiste Leduc + * + * @template T of BuiltinType|BuiltinType|ObjectType + */ +final class GenericType extends Type +{ + /** + * @var list + */ + private readonly array $variableTypes; + + /** + * @param T $type + */ + public function __construct( + private readonly BuiltinType|ObjectType $type, + Type ...$variableTypes, + ) { + $this->variableTypes = $variableTypes; + } + + /** + * @return T + */ + public function getType(): BuiltinType|ObjectType + { + return $this->type; + } + + public function asNonNullable(): self + { + return $this; + } + + /** + * @return list + */ + public function getVariableTypes(): array + { + return $this->variableTypes; + } + + public function __toString(): string + { + $typeString = (string) $this->type; + + $variableTypesString = ''; + $glue = ''; + foreach ($this->variableTypes as $t) { + $variableTypesString .= $glue.((string) $t); + $glue = ','; + } + + return $typeString.'<'.$variableTypesString.'>'; + } + + /** + * Proxies all method calls to the original type. + * + * @param list $arguments + */ + public function __call(string $method, array $arguments): mixed + { + return $this->type->{$method}(...$arguments); + } +} diff --git a/src/Symfony/Component/TypeInfo/Type/IntersectionType.php b/src/Symfony/Component/TypeInfo/Type/IntersectionType.php new file mode 100644 index 0000000000000..c8a0bd03d501d --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Type/IntersectionType.php @@ -0,0 +1,51 @@ + + * + * 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\Exception\LogicException; +use Symfony\Component\TypeInfo\Type; + +/** + * @author Mathias Arlaud + * @author Baptiste Leduc + * + * @template T of Type + */ +final class IntersectionType extends Type +{ + /** + * @use CompositeTypeTrait + */ + use CompositeTypeTrait; + + public function __toString(): string + { + $string = ''; + $glue = ''; + + foreach ($this->types as $t) { + $string .= $glue.($t instanceof UnionType ? '('.((string) $t).')' : ((string) $t)); + $glue = '&'; + } + + return $string; + } + + public function asNonNullable(): self + { + if ($this->isNullable()) { + throw new LogicException(sprintf('"%s cannot be turned as non nullable.', (string) $this)); + } + + return $this; + } +} diff --git a/src/Symfony/Component/TypeInfo/Type/ObjectType.php b/src/Symfony/Component/TypeInfo/Type/ObjectType.php new file mode 100644 index 0000000000000..e10d90bd16612 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Type/ObjectType.php @@ -0,0 +1,55 @@ + + * + * 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\Type; +use Symfony\Component\TypeInfo\TypeIdentifier; + +/** + * @author Mathias Arlaud + * @author Baptiste Leduc + * + * @template T of class-string + */ +class ObjectType extends Type +{ + /** + * @param T $className + */ + public function __construct( + private readonly string $className, + ) { + } + + public function getTypeIdentifier(): TypeIdentifier + { + return TypeIdentifier::OBJECT; + } + + /** + * @return T + */ + public function getClassName(): string + { + return $this->className; + } + + public function asNonNullable(): static + { + return $this; + } + + public function __toString(): string + { + return $this->className; + } +} diff --git a/src/Symfony/Component/TypeInfo/Type/TemplateType.php b/src/Symfony/Component/TypeInfo/Type/TemplateType.php new file mode 100644 index 0000000000000..a96b61293ec75 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Type/TemplateType.php @@ -0,0 +1,49 @@ + + * + * 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\Type; + +/** + * Represents a template placeholder, such as "T" in "Collection". + * + * @author Mathias Arlaud + * @author Baptiste Leduc + */ +final class TemplateType extends Type +{ + public function __construct( + private readonly string $name, + private readonly Type $bound, + ) { + } + + public function getName(): string + { + return $this->name; + } + + public function getBound(): Type + { + return $this->bound; + } + + public function asNonNullable(): self + { + return $this; + } + + public function __toString(): string + { + return $this->name; + } +} diff --git a/src/Symfony/Component/TypeInfo/Type/UnionType.php b/src/Symfony/Component/TypeInfo/Type/UnionType.php new file mode 100644 index 0000000000000..02ace46087d6a --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Type/UnionType.php @@ -0,0 +1,60 @@ + + * + * 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\Type; +use Symfony\Component\TypeInfo\TypeIdentifier; + +/** + * @author Mathias Arlaud + * @author Baptiste Leduc + * + * @template T of Type + */ +final class UnionType extends Type +{ + /** + * @use CompositeTypeTrait + */ + use CompositeTypeTrait; + + public function asNonNullable(): Type + { + $nonNullableTypes = []; + foreach ($this->getTypes() as $type) { + if ($type->isA(TypeIdentifier::NULL)) { + continue; + } + + $nonNullableType = $type->asNonNullable(); + $nonNullableTypes = [ + ...$nonNullableTypes, + ...($nonNullableType instanceof self ? $nonNullableType->getTypes() : [$nonNullableType]), + ]; + } + + return \count($nonNullableTypes) > 1 ? new self(...$nonNullableTypes) : $nonNullableTypes[0]; + } + + public function __toString(): string + { + $string = ''; + $glue = ''; + + foreach ($this->types as $t) { + $string .= $glue.($t instanceof IntersectionType ? '('.((string) $t).')' : ((string) $t)); + $glue = '|'; + } + + return $string; + } +} diff --git a/src/Symfony/Component/TypeInfo/TypeContext/TypeContext.php b/src/Symfony/Component/TypeInfo/TypeContext/TypeContext.php new file mode 100644 index 0000000000000..60638b95616e8 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/TypeContext/TypeContext.php @@ -0,0 +1,116 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo\TypeContext; + +use Symfony\Component\TypeInfo\Exception\LogicException; +use Symfony\Component\TypeInfo\Type; + +/** + * Type resolving context. + * + * Helps to retrieve declaring class, called class, parent class, templates + * and normalize classes according to the current namespace and uses. + * + * @author Mathias Arlaud + * @author Baptiste Leduc + */ +final class TypeContext +{ + /** + * @var array + */ + private static array $classExistCache = []; + + /** + * @param array $uses + * @param array $templates + */ + public function __construct( + public readonly string $calledClassName, + public readonly string $declaringClassName, + public readonly ?string $namespace = null, + public readonly array $uses = [], + public readonly array $templates = [], + ) { + } + + /** + * Normalize class name according to current namespace and uses. + */ + public function normalize(string $name): string + { + if (str_starts_with($name, '\\')) { + return ltrim($name, '\\'); + } + + $nameParts = explode('\\', $name); + $firstNamePart = $nameParts[0]; + if (isset($this->uses[$firstNamePart])) { + if (1 === \count($nameParts)) { + return $this->uses[$firstNamePart]; + } + array_shift($nameParts); + + return sprintf('%s\\%s', $this->uses[$firstNamePart], implode('\\', $nameParts)); + } + + if (null !== $this->namespace) { + return sprintf('%s\\%s', $this->namespace, $name); + } + + return $name; + } + + /** + * @return class-string + */ + public function getDeclaringClass(): string + { + return $this->normalize($this->declaringClassName); + } + + /** + * @return class-string + */ + public function getCalledClass(): string + { + return $this->normalize($this->calledClassName); + } + + /** + * @return class-string + */ + public function getParentClass(): string + { + $declaringClassName = $this->getDeclaringClass(); + + if (false === $parentClass = get_parent_class($declaringClassName)) { + throw new LogicException(sprintf('"%s" do not extend any class.', $declaringClassName)); + } + + if (!isset(self::$classExistCache[$parentClass])) { + self::$classExistCache[$parentClass] = false; + + if (class_exists($parentClass)) { + self::$classExistCache[$parentClass] = true; + } else { + try { + new \ReflectionClass($parentClass); + self::$classExistCache[$parentClass] = true; + } catch (\Throwable) { + } + } + } + + return self::$classExistCache[$parentClass] ? $parentClass : $this->normalize(str_replace($this->namespace.'\\', '', $parentClass)); + } +} diff --git a/src/Symfony/Component/TypeInfo/TypeContext/TypeContextFactory.php b/src/Symfony/Component/TypeInfo/TypeContext/TypeContextFactory.php new file mode 100644 index 0000000000000..e5d17e0a30801 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/TypeContext/TypeContextFactory.php @@ -0,0 +1,184 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo\TypeContext; + +use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; +use PHPStan\PhpDocParser\Lexer\Lexer; +use PHPStan\PhpDocParser\Parser\ConstExprParser; +use PHPStan\PhpDocParser\Parser\PhpDocParser; +use PHPStan\PhpDocParser\Parser\TokenIterator; +use PHPStan\PhpDocParser\Parser\TypeParser; +use Symfony\Component\TypeInfo\Exception\RuntimeException; +use Symfony\Component\TypeInfo\Exception\UnsupportedException; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeResolver\StringTypeResolver; + +/** + * Creates a type resolving context. + * + * @author Mathias Arlaud + * @author Baptiste Leduc + */ +final class TypeContextFactory +{ + /** + * @var array + */ + private static array $reflectionClassCache = []; + + private ?Lexer $phpstanLexer = null; + private ?PhpDocParser $phpstanParser = null; + + public function __construct( + private readonly ?StringTypeResolver $stringTypeResolver = null, + ) { + } + + public function createFromClassName(string $calledClassName, string $declaringClassName = null): TypeContext + { + $declaringClassName ??= $calledClassName; + + $calledClassPath = explode('\\', $calledClassName); + $declaringClassPath = explode('\\', $declaringClassName); + + $declaringClassReflection = (self::$reflectionClassCache[$declaringClassName] ??= new \ReflectionClass($declaringClassName)); + + $typeContext = new TypeContext( + array_pop($calledClassPath), + array_pop($declaringClassPath), + trim($declaringClassReflection->getNamespaceName(), '\\'), + $this->collectUses($declaringClassReflection), + ); + + return new TypeContext( + $typeContext->calledClassName, + $typeContext->declaringClassName, + $typeContext->namespace, + $typeContext->uses, + $this->collectTemplates($declaringClassReflection, $typeContext), + ); + } + + public function createFromReflection(\Reflector $reflection): ?TypeContext + { + $declaringClassReflection = match (true) { + $reflection instanceof \ReflectionClass => $reflection, + $reflection instanceof \ReflectionMethod => $reflection->getDeclaringClass(), + $reflection instanceof \ReflectionProperty => $reflection->getDeclaringClass(), + $reflection instanceof \ReflectionParameter => $reflection->getDeclaringClass(), + $reflection instanceof \ReflectionFunctionAbstract => $reflection->getClosureScopeClass(), + default => null, + }; + + if (null === $declaringClassReflection) { + return null; + } + + $typeContext = new TypeContext( + $declaringClassReflection->getShortName(), + $declaringClassReflection->getShortName(), + $declaringClassReflection->getNamespaceName(), + $this->collectUses($declaringClassReflection), + ); + + $templates = match (true) { + $reflection instanceof \ReflectionFunctionAbstract => $this->collectTemplates($reflection, $typeContext) + $this->collectTemplates($declaringClassReflection, $typeContext), + $reflection instanceof \ReflectionParameter => $this->collectTemplates($reflection->getDeclaringFunction(), $typeContext) + $this->collectTemplates($declaringClassReflection, $typeContext), + default => $this->collectTemplates($declaringClassReflection, $typeContext), + }; + + return new TypeContext( + $typeContext->calledClassName, + $typeContext->declaringClassName, + $typeContext->namespace, + $typeContext->uses, + $templates, + ); + } + + /** + * @return array + */ + private function collectUses(\ReflectionClass $reflection): array + { + $fileName = $reflection->getFileName(); + if (!\is_string($fileName) || !is_file($fileName)) { + return []; + } + + if (false === $lines = @file($fileName)) { + throw new RuntimeException(sprintf('Unable to read file "%s".', $fileName)); + } + + $uses = []; + $inUseSection = false; + + foreach ($lines as $line) { + if (str_starts_with($line, 'use ')) { + $inUseSection = true; + $use = explode(' as ', substr($line, 4, -2), 2); + + $alias = 1 === \count($use) ? substr($use[0], false !== ($p = strrpos($use[0], '\\')) ? 1 + $p : 0) : $use[1]; + $uses[$alias] = $use[0]; + } elseif ($inUseSection) { + break; + } + } + + $traitUses = []; + foreach ($reflection->getTraits() as $traitReflection) { + $traitUses[] = $this->collectUses($traitReflection); + } + + return array_merge($uses, ...$traitUses); + } + + /** + * @return array + */ + private function collectTemplates(\ReflectionClass|\ReflectionFunctionAbstract $reflection, TypeContext $typeContext): array + { + if (!$this->stringTypeResolver || !class_exists(PhpDocParser::class)) { + return []; + } + + if (!$rawDocNode = $reflection->getDocComment()) { + return []; + } + + $this->phpstanLexer ??= new Lexer(); + $this->phpstanParser ??= new PhpDocParser(new TypeParser(new ConstExprParser()), new ConstExprParser()); + + $tokens = new TokenIterator($this->phpstanLexer->tokenize($rawDocNode)); + + $templates = []; + foreach ($this->phpstanParser->parse($tokens)->getTagsByName('@template') as $tag) { + if (!$tag->value instanceof TemplateTagValueNode) { + continue; + } + + $type = Type::mixed(); + $typeString = ((string) $tag->value->bound) ?: null; + + try { + if (null !== $typeString) { + $type = $this->stringTypeResolver->resolve($typeString, $typeContext); + } + } catch (UnsupportedException) { + } + + $templates[$tag->value->name] = $type; + } + + return $templates; + } +} diff --git a/src/Symfony/Component/TypeInfo/TypeFactoryTrait.php b/src/Symfony/Component/TypeInfo/TypeFactoryTrait.php new file mode 100644 index 0000000000000..f56c17b16c789 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/TypeFactoryTrait.php @@ -0,0 +1,319 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo; + +use Symfony\Component\TypeInfo\Type\BackedEnumType; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\EnumType; +use Symfony\Component\TypeInfo\Type\GenericType; +use Symfony\Component\TypeInfo\Type\IntersectionType; +use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\Type\TemplateType; +use Symfony\Component\TypeInfo\Type\UnionType; + +/** + * Helper trait to create any type easily. + * + * @author Mathias Arlaud + * @author Baptiste Leduc + */ +trait TypeFactoryTrait +{ + /** + * @template T of TypeIdentifier + * @template U value-of + * + * @param T|U $identifier + * + * @return BuiltinType + */ + public static function builtin(TypeIdentifier|string $identifier): BuiltinType + { + /** @var T $identifier */ + $identifier = \is_string($identifier) ? TypeIdentifier::from($identifier) : $identifier; + + return new BuiltinType($identifier); + } + + public static function foo(): BackedEnumType + { + return new BackedEnumType(\BackedEnum::class, Type::int()); + } + + /** + * @return BuiltinType + */ + public static function int(): BuiltinType + { + return self::builtin(TypeIdentifier::INT); + } + + /** + * @return BuiltinType + */ + public static function float(): BuiltinType + { + return self::builtin(TypeIdentifier::FLOAT); + } + + /** + * @return BuiltinType + */ + public static function string(): BuiltinType + { + return self::builtin(TypeIdentifier::STRING); + } + + /** + * @return BuiltinType + */ + public static function bool(): BuiltinType + { + return self::builtin(TypeIdentifier::BOOL); + } + + /** + * @return BuiltinType + */ + public static function resource(): BuiltinType + { + return self::builtin(TypeIdentifier::RESOURCE); + } + + /** + * @return BuiltinType + */ + public static function false(): BuiltinType + { + return self::builtin(TypeIdentifier::FALSE); + } + + /** + * @return BuiltinType + */ + public static function true(): BuiltinType + { + return self::builtin(TypeIdentifier::TRUE); + } + + /** + * @return BuiltinType + */ + public static function callable(): BuiltinType + { + return self::builtin(TypeIdentifier::CALLABLE); + } + + /** + * @return BuiltinType + */ + public static function mixed(): BuiltinType + { + return self::builtin(TypeIdentifier::MIXED); + } + + /** + * @return BuiltinType + */ + public static function null(): BuiltinType + { + return self::builtin(TypeIdentifier::NULL); + } + + /** + * @return BuiltinType + */ + public static function void(): BuiltinType + { + return self::builtin(TypeIdentifier::VOID); + } + + /** + * @return BuiltinType + */ + public static function never(): BuiltinType + { + return self::builtin(TypeIdentifier::NEVER); + } + + /** + * @template T of BuiltinType|BuiltinType|ObjectType|GenericType + * + * @param T $type + * + * @return CollectionType + */ + public static function collection(BuiltinType|ObjectType|GenericType $type, Type $value = null, Type $key = null, bool $asList = false): CollectionType + { + if (!$type instanceof GenericType && (null !== $value || null !== $key)) { + $type = self::generic($type, $key ?? self::union(self::int(), self::string()), $value ?? self::mixed()); + } + + return new CollectionType($type, $asList); + } + + /** + * @return CollectionType> + */ + public static function array(Type $value = null, Type $key = null, bool $asList = false): CollectionType + { + return self::collection(self::builtin(TypeIdentifier::ARRAY), $value, $key, $asList); + } + + /** + * @return CollectionType> + */ + public static function iterable(Type $value = null, Type $key = null, bool $asList = false): CollectionType + { + return self::collection(self::builtin(TypeIdentifier::ITERABLE), $value, $key, $asList); + } + + /** + * @return CollectionType> + */ + public static function list(Type $value = null): CollectionType + { + return self::array($value, self::int(), asList: true); + } + + /** + * @return CollectionType> + */ + public static function dict(Type $value = null): CollectionType + { + return self::array($value, self::string()); + } + + /** + * @template T of class-string + * + * @param T|null $className + * + * @return ($className is class-string ? ObjectType : BuiltinType) + */ + public static function object(string $className = null): BuiltinType|ObjectType + { + return null !== $className ? new ObjectType($className) : new BuiltinType(TypeIdentifier::OBJECT); + } + + /** + * @template T of class-string<\UnitEnum>|class-string<\BackedEnum> + * @template U of BuiltinType|BuiltinType + * + * @param T $className + * @param U|null $backingType + * + * @return ($className is class-string<\BackedEnum> ? ($backingType is U ? BackedEnumType : BackedEnumType|BuiltinType>) : EnumType)) + */ + public static function enum(string $className, BuiltinType $backingType = null): EnumType + { + if (is_subclass_of($className, \BackedEnum::class)) { + if (null === $backingType) { + $reflectionBackingType = (new \ReflectionEnum($className))->getBackingType(); + $typeIdentifier = TypeIdentifier::INT->value === (string) $reflectionBackingType ? TypeIdentifier::INT : TypeIdentifier::STRING; + $backingType = new BuiltinType($typeIdentifier); + } + + return new BackedEnumType($className, $backingType); + } + + return new EnumType($className); + } + + /** + * @template T of BuiltinType|BuiltinType|ObjectType + * + * @param T $mainType + * + * @return GenericType + */ + public static function generic(Type $mainType, Type ...$variableTypes): GenericType + { + return new GenericType($mainType, ...$variableTypes); + } + + public static function template(string $name, Type $bound = null): TemplateType + { + return new TemplateType($name, $bound ?? Type::mixed()); + } + + /** + * @template T of Type + * + * @param list $types + * + * @return UnionType + */ + public static function union(Type ...$types): UnionType + { + /** @var list $unionTypes */ + $unionTypes = []; + + foreach ($types as $type) { + if (!$type instanceof UnionType) { + $unionTypes[] = $type; + + continue; + } + + foreach ($type->getTypes() as $unionType) { + $unionTypes[] = $unionType; + } + } + + return new UnionType(...$unionTypes); + } + + /** + * @template T of Type + * + * @param list $types + * + * @return IntersectionType + */ + public static function intersection(Type ...$types): IntersectionType + { + /** @var list $intersectionTypes */ + $intersectionTypes = []; + + foreach ($types as $type) { + if (!$type instanceof IntersectionType) { + $intersectionTypes[] = $type; + + continue; + } + + foreach ($type->getTypes() as $intersectionType) { + $intersectionTypes[] = $intersectionType; + } + } + + return new IntersectionType(...$intersectionTypes); + } + + /** + * @template T of Type + * + * @param T $type + * + * @return (T is UnionType ? T : UnionType>) + */ + public static function nullable(Type $type): UnionType + { + if ($type instanceof UnionType) { + return Type::union(Type::null(), ...$type->getTypes()); + } + + return Type::union($type, Type::null()); + } +} diff --git a/src/Symfony/Component/TypeInfo/TypeIdentifier.php b/src/Symfony/Component/TypeInfo/TypeIdentifier.php new file mode 100644 index 0000000000000..5326262a8562d --- /dev/null +++ b/src/Symfony/Component/TypeInfo/TypeIdentifier.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo; + +/** + * Identifier of a PHP native type. + * + * @author Mathias Arlaud + * @author Baptiste Leduc + */ +enum TypeIdentifier: string +{ + case ARRAY = 'array'; + case BOOL = 'bool'; + case CALLABLE = 'callable'; + case FALSE = 'false'; + case FLOAT = 'float'; + case INT = 'int'; + case ITERABLE = 'iterable'; + case MIXED = 'mixed'; + case NULL = 'null'; + case OBJECT = 'object'; + case RESOURCE = 'resource'; + case STRING = 'string'; + case TRUE = 'true'; + case NEVER = 'never'; + case VOID = 'void'; +} diff --git a/src/Symfony/Component/TypeInfo/TypeResolver/ReflectionParameterTypeResolver.php b/src/Symfony/Component/TypeInfo/TypeResolver/ReflectionParameterTypeResolver.php new file mode 100644 index 0000000000000..83d94f90b6fa6 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/TypeResolver/ReflectionParameterTypeResolver.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo\TypeResolver; + +use Symfony\Component\TypeInfo\Exception\UnsupportedException; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeContext\TypeContext; +use Symfony\Component\TypeInfo\TypeContext\TypeContextFactory; + +/** + * Resolves type for a given parameter reflection. + * + * @author Mathias Arlaud + * @author Baptiste Leduc + * + * @internal + */ +final readonly class ReflectionParameterTypeResolver implements TypeResolverInterface +{ + public function __construct( + private ReflectionTypeResolver $reflectionTypeResolver, + private TypeContextFactory $typeContextFactory, + ) { + } + + public function resolve(mixed $subject, TypeContext $typeContext = null): Type + { + if (!$subject instanceof \ReflectionParameter) { + throw new UnsupportedException(sprintf('Expected subject to be a "ReflectionParameter", "%s" given.', get_debug_type($subject)), $subject); + } + + $typeContext ??= $this->typeContextFactory->createFromReflection($subject); + + try { + return $this->reflectionTypeResolver->resolve($subject->getType(), $typeContext); + } catch (UnsupportedException $e) { + $path = null !== $typeContext + ? sprintf('%s::%s($%s)', $typeContext->calledClassName, $subject->getDeclaringFunction()->getName(), $subject->getName()) + : sprintf('%s($%s)', $subject->getDeclaringFunction()->getName(), $subject->getName()); + + throw new UnsupportedException(sprintf('Cannot resolve type for "%s".', $path), $subject, previous: $e); + } + } +} diff --git a/src/Symfony/Component/TypeInfo/TypeResolver/ReflectionPropertyTypeResolver.php b/src/Symfony/Component/TypeInfo/TypeResolver/ReflectionPropertyTypeResolver.php new file mode 100644 index 0000000000000..a6c55a411b5ac --- /dev/null +++ b/src/Symfony/Component/TypeInfo/TypeResolver/ReflectionPropertyTypeResolver.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo\TypeResolver; + +use Symfony\Component\TypeInfo\Exception\UnsupportedException; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeContext\TypeContext; +use Symfony\Component\TypeInfo\TypeContext\TypeContextFactory; + +/** + * Resolves type for a given property reflection. + * + * @author Mathias Arlaud + * @author Baptiste Leduc + * + * @internal + */ +final readonly class ReflectionPropertyTypeResolver implements TypeResolverInterface +{ + public function __construct( + private ReflectionTypeResolver $reflectionTypeResolver, + private TypeContextFactory $typeContextFactory, + ) { + } + + public function resolve(mixed $subject, TypeContext $typeContext = null): Type + { + if (!$subject instanceof \ReflectionProperty) { + throw new UnsupportedException(sprintf('Expected subject to be a "ReflectionProperty", "%s" given.', get_debug_type($subject)), $subject); + } + + $typeContext ??= $this->typeContextFactory->createFromReflection($subject); + + try { + return $this->reflectionTypeResolver->resolve($subject->getType(), $typeContext); + } catch (UnsupportedException $e) { + $path = sprintf('%s::$%s', $subject->getDeclaringClass()->getName(), $subject->getName()); + + throw new UnsupportedException(sprintf('Cannot resolve type for "%s".', $path), $subject, previous: $e); + } + } +} diff --git a/src/Symfony/Component/TypeInfo/TypeResolver/ReflectionReturnTypeResolver.php b/src/Symfony/Component/TypeInfo/TypeResolver/ReflectionReturnTypeResolver.php new file mode 100644 index 0000000000000..feab41730b0e6 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/TypeResolver/ReflectionReturnTypeResolver.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo\TypeResolver; + +use Symfony\Component\TypeInfo\Exception\UnsupportedException; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeContext\TypeContext; +use Symfony\Component\TypeInfo\TypeContext\TypeContextFactory; + +/** + * Resolves return type for a given function reflection. + * + * @author Mathias Arlaud + * @author Baptiste Leduc + * + * @internal + */ +final readonly class ReflectionReturnTypeResolver implements TypeResolverInterface +{ + public function __construct( + private ReflectionTypeResolver $reflectionTypeResolver, + private TypeContextFactory $typeContextFactory, + ) { + } + + public function resolve(mixed $subject, TypeContext $typeContext = null): Type + { + if (!$subject instanceof \ReflectionFunctionAbstract) { + throw new UnsupportedException(sprintf('Expected subject to be a "ReflectionFunctionAbstract", "%s" given.', get_debug_type($subject)), $subject); + } + + $typeContext ??= $this->typeContextFactory->createFromReflection($subject); + + try { + return $this->reflectionTypeResolver->resolve($subject->getReturnType(), $typeContext); + } catch (UnsupportedException $e) { + $path = null !== $typeContext + ? sprintf('%s::%s()', $typeContext->calledClassName, $subject->getName()) + : sprintf('%s()', $subject->getName()); + + throw new UnsupportedException(sprintf('Cannot resolve type for "%s".', $path), $subject, previous: $e); + } + } +} diff --git a/src/Symfony/Component/TypeInfo/TypeResolver/ReflectionTypeResolver.php b/src/Symfony/Component/TypeInfo/TypeResolver/ReflectionTypeResolver.php new file mode 100644 index 0000000000000..193086aef1017 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/TypeResolver/ReflectionTypeResolver.php @@ -0,0 +1,98 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo\TypeResolver; + +use Symfony\Component\TypeInfo\Exception\InvalidArgumentException; +use Symfony\Component\TypeInfo\Exception\UnsupportedException; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeContext\TypeContext; +use Symfony\Component\TypeInfo\TypeIdentifier; + +/** + * Resolves type for a given type reflection. + * + * @author Mathias Arlaud + * @author Baptiste Leduc + * + * @internal + */ +final class ReflectionTypeResolver implements TypeResolverInterface +{ + /** + * @var array + */ + private static array $reflectionEnumCache = []; + + public function resolve(mixed $subject, TypeContext $typeContext = null): Type + { + if ($subject instanceof \ReflectionUnionType) { + return Type::union(...array_map(fn (mixed $t): Type => $this->resolve($t, $typeContext), $subject->getTypes())); + } + + if ($subject instanceof \ReflectionIntersectionType) { + return Type::intersection(...array_map(fn (mixed $t): Type => $this->resolve($t, $typeContext), $subject->getTypes())); + } + + if (!$subject instanceof \ReflectionNamedType) { + throw new UnsupportedException(sprintf('Expected subject to be a "ReflectionNamedType", a "ReflectionUnionType" or a "ReflectionIntersectionType", "%s" given.', get_debug_type($subject)), $subject); + } + + $identifier = $subject->getName(); + $nullable = $subject->allowsNull(); + + if (TypeIdentifier::ARRAY->value === $identifier) { + $type = Type::array(); + + return $nullable ? Type::nullable($type) : $type; + } + + if (TypeIdentifier::ITERABLE->value === $identifier) { + $type = Type::iterable(); + + return $nullable ? Type::nullable($type) : $type; + } + + if (TypeIdentifier::NULL->value === $identifier || TypeIdentifier::MIXED->value === $identifier) { + return Type::builtin($identifier); + } + + if ($subject->isBuiltin()) { + $type = Type::builtin(TypeIdentifier::from($identifier)); + + return $nullable ? Type::nullable($type) : $type; + } + + if (\in_array(strtolower($identifier), ['self', 'static', 'parent'], true) && !$typeContext) { + throw new InvalidArgumentException(sprintf('A "%s" must be provided to resolve "%s".', TypeContext::class, strtolower($identifier))); + } + + /** @var class-string $className */ + $className = match (true) { + 'self' === strtolower($identifier) => $typeContext->getDeclaringClass(), + 'static' === strtolower($identifier) => $typeContext->getCalledClass(), + 'parent' === strtolower($identifier) => $typeContext->getParentClass(), + default => $identifier, + }; + + if (is_subclass_of($className, \BackedEnum::class)) { + $reflectionEnum = (self::$reflectionEnumCache[$className] ??= new \ReflectionEnum($className)); + $backingType = $this->resolve($reflectionEnum->getBackingType(), $typeContext); + $type = Type::enum($className, $backingType); + } elseif (is_subclass_of($className, \UnitEnum::class)) { + $type = Type::enum($className); + } else { + $type = Type::object($className); + } + + return $nullable ? Type::nullable($type) : $type; + } +} diff --git a/src/Symfony/Component/TypeInfo/TypeResolver/StringTypeResolver.php b/src/Symfony/Component/TypeInfo/TypeResolver/StringTypeResolver.php new file mode 100644 index 0000000000000..de90a5073fc60 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/TypeResolver/StringTypeResolver.php @@ -0,0 +1,255 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo\TypeResolver; + +use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprArrayNode; +use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprFalseNode; +use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprFloatNode; +use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprIntegerNode; +use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprNullNode; +use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprStringNode; +use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprTrueNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; +use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode; +use PHPStan\PhpDocParser\Ast\Type\ConstTypeNode; +use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; +use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; +use PHPStan\PhpDocParser\Ast\Type\IntersectionTypeNode; +use PHPStan\PhpDocParser\Ast\Type\NullableTypeNode; +use PHPStan\PhpDocParser\Ast\Type\ObjectShapeNode; +use PHPStan\PhpDocParser\Ast\Type\ThisTypeNode; +use PHPStan\PhpDocParser\Ast\Type\TypeNode; +use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode; +use PHPStan\PhpDocParser\Lexer\Lexer; +use PHPStan\PhpDocParser\Parser\ConstExprParser; +use PHPStan\PhpDocParser\Parser\TokenIterator; +use PHPStan\PhpDocParser\Parser\TypeParser; +use Symfony\Component\TypeInfo\Exception\InvalidArgumentException; +use Symfony\Component\TypeInfo\Exception\UnsupportedException; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\GenericType; +use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\TypeContext\TypeContext; +use Symfony\Component\TypeInfo\TypeIdentifier; + +/** + * Resolves type for a given string. + * + * @author Mathias Arlaud + * @author Baptiste Leduc + * + * @internal + */ +final class StringTypeResolver implements TypeResolverInterface +{ + private const COLLECTION_CLASS_NAMES = [\Traversable::class, \Iterator::class, \IteratorAggregate::class, \ArrayAccess::class, \Generator::class]; + + /** + * @var array + */ + private static array $classExistCache = []; + + private readonly Lexer $lexer; + private readonly TypeParser $parser; + + public function __construct() + { + $this->lexer = new Lexer(); + $this->parser = new TypeParser(new ConstExprParser()); + } + + public function resolve(mixed $subject, TypeContext $typeContext = null): Type + { + if (!\is_string($subject)) { + throw new UnsupportedException(sprintf('Expected subject to be a "string", "%s" given.', get_debug_type($subject)), $subject); + } + + try { + $tokens = new TokenIterator($this->lexer->tokenize($subject)); + $node = $this->parser->parse($tokens); + + return $this->getTypeFromNode($node, $typeContext); + } catch (\DomainException $e) { + throw new UnsupportedException(sprintf('Cannot resolve "%s".', $subject), $subject, previous: $e); + } + } + + private function getTypeFromNode(TypeNode $node, ?TypeContext $typeContext): Type + { + if ($node instanceof CallableTypeNode) { + return Type::callable(); + } + + if ($node instanceof ArrayTypeNode) { + return Type::array($this->getTypeFromNode($node->type, $typeContext)); + } + + if ($node instanceof ArrayShapeNode) { + return Type::array(); + } + + if ($node instanceof ObjectShapeNode) { + return Type::object(); + } + + if ($node instanceof ThisTypeNode) { + if (null === $typeContext) { + throw new InvalidArgumentException(sprintf('A "%s" must be provided to resolve "$this".', TypeContext::class)); + } + + return Type::object($typeContext->getCalledClass()); + } + + if ($node instanceof ConstTypeNode) { + return match ($node->constExpr::class) { + ConstExprArrayNode::class => Type::array(), + ConstExprFalseNode::class => Type::false(), + ConstExprFloatNode::class => Type::float(), + ConstExprIntegerNode::class => Type::int(), + ConstExprNullNode::class => Type::null(), + ConstExprStringNode::class => Type::string(), + ConstExprTrueNode::class => Type::true(), + default => throw new \DomainException(sprintf('Unhandled "%s" constant expression.', $node->constExpr::class)), + }; + } + + if ($node instanceof IdentifierTypeNode) { + $type = match ($node->name) { + '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(), + 'float', 'double' => Type::float(), + 'string', + 'class-string', + 'trait-string', + 'interface-string', + 'callable-string', + 'numeric-string', + 'lowercase-string', + 'non-empty-lowercase-string', + 'non-empty-string', + 'non-falsy-string', + 'truthy-string', + 'literal-string', + 'html-escaped-string' => Type::string(), + 'resource' => Type::resource(), + 'object' => Type::object(), + 'callable' => Type::callable(), + 'array', 'non-empty-array' => Type::array(), + 'list', 'non-empty-list' => Type::list(), + 'iterable' => Type::iterable(), + 'mixed' => Type::mixed(), + 'null' => Type::null(), + 'array-key' => Type::union(Type::int(), Type::string()), + 'scalar' => Type::union(Type::int(), Type::float(), Type::string(), Type::bool()), + 'number' => Type::union(Type::int(), Type::float()), + 'numeric' => Type::union(Type::int(), Type::float(), Type::string()), + 'self' => $typeContext ? Type::object($typeContext->getDeclaringClass()) : throw new InvalidArgumentException(sprintf('A "%s" must be provided to resolve "self".', TypeContext::class)), + 'static' => $typeContext ? Type::object($typeContext->getCalledClass()) : throw new InvalidArgumentException(sprintf('A "%s" must be provided to resolve "static".', TypeContext::class)), + 'parent' => $typeContext ? Type::object($typeContext->getParentClass()) : throw new InvalidArgumentException(sprintf('A "%s" must be provided to resolve "parent".', TypeContext::class)), + 'void' => Type::void(), + 'never', 'never-return', 'never-returns', 'no-return' => Type::never(), + default => $this->resolveCustomIdentifier($node->name, $typeContext), + }; + + if ($type instanceof ObjectType && \in_array($type->getClassName(), self::COLLECTION_CLASS_NAMES, true)) { + return Type::collection($type); + } + + return $type; + } + + if ($node instanceof NullableTypeNode) { + return Type::nullable($this->getTypeFromNode($node->type, $typeContext)); + } + + if ($node instanceof GenericTypeNode) { + $type = $this->getTypeFromNode($node->type, $typeContext); + + // handle integer ranges as simple integers + if ($type->isA(TypeIdentifier::INT)) { + return $type; + } + + $variableTypes = array_map(fn (TypeNode $t): Type => $this->getTypeFromNode($t, $typeContext), $node->genericTypes); + + if ($type instanceof CollectionType) { + $keyType = $type->getCollectionKeyType(); + + $type = $type->getType(); + if ($type instanceof GenericType) { + $type = $type->getType(); + } + + if (1 === \count($variableTypes)) { + return Type::collection($type, $variableTypes[0], $keyType); + } elseif (2 === \count($variableTypes)) { + return Type::collection($type, $variableTypes[1], $variableTypes[0]); + } + } + + if ($type instanceof ObjectType && \in_array($type->getClassName(), self::COLLECTION_CLASS_NAMES, true)) { + return match (\count($variableTypes)) { + 1 => Type::collection($type, $variableTypes[0]), + 2 => Type::collection($type, $variableTypes[1], $variableTypes[0]), + default => Type::collection($type), + }; + } + + return Type::generic($type, ...$variableTypes); + } + + if ($node instanceof UnionTypeNode) { + return Type::union(...array_map(fn (TypeNode $t): Type => $this->getTypeFromNode($t, $typeContext), $node->types)); + } + + if ($node instanceof IntersectionTypeNode) { + return Type::intersection(...array_map(fn (TypeNode $t): Type => $this->getTypeFromNode($t, $typeContext), $node->types)); + } + + throw new \DomainException(sprintf('Unhandled "%s" node.', $node::class)); + } + + private function resolveCustomIdentifier(string $identifier, ?TypeContext $typeContext): Type + { + $className = $typeContext ? $typeContext->normalize($identifier) : $identifier; + + if (!isset(self::$classExistCache[$className])) { + self::$classExistCache[$className] = false; + + if (class_exists($className) || interface_exists($className)) { + self::$classExistCache[$className] = true; + } else { + try { + new \ReflectionClass($className); + self::$classExistCache[$className] = true; + + return Type::object($className); + } catch (\Throwable) { + } + } + } + + if (self::$classExistCache[$className]) { + return Type::object($className); + } + + if (isset($typeContext?->templates[$identifier])) { + return Type::template($identifier, $typeContext->templates[$identifier]); + } + + throw new \DomainException(sprintf('Unhandled "%s" identifier.', $identifier)); + } +} diff --git a/src/Symfony/Component/TypeInfo/TypeResolver/TypeResolver.php b/src/Symfony/Component/TypeInfo/TypeResolver/TypeResolver.php new file mode 100644 index 0000000000000..173dc937381ed --- /dev/null +++ b/src/Symfony/Component/TypeInfo/TypeResolver/TypeResolver.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo\TypeResolver; + +use PHPStan\PhpDocParser\Parser\PhpDocParser; +use Psr\Container\ContainerInterface; +use Symfony\Component\TypeInfo\Exception\UnsupportedException; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeContext\TypeContext; +use Symfony\Component\TypeInfo\TypeContext\TypeContextFactory; + +/** + * Resolves type for a given subject by delegating resolving to nested type resolvers. + * + * @author Mathias Arlaud + * @author Baptiste Leduc + */ +final readonly class TypeResolver implements TypeResolverInterface +{ + /** + * @param ContainerInterface $resolvers Locator of type resolvers, keyed by supported subject type + */ + public function __construct( + private ContainerInterface $resolvers, + ) { + } + + public function resolve(mixed $subject, TypeContext $typeContext = null): Type + { + $subjectType = match (\is_object($subject)) { + true => match (true) { + is_subclass_of($subject::class, \ReflectionType::class) => \ReflectionType::class, + is_subclass_of($subject::class, \ReflectionFunctionAbstract::class) => \ReflectionFunctionAbstract::class, + default => $subject::class, + }, + false => get_debug_type($subject), + }; + + if (!$this->resolvers->has($subjectType)) { + if ('string' === $subjectType) { + throw new UnsupportedException('Cannot find any resolver for "string" type. Try running "composer require phpstan/phpdoc-parser".', $subject); + } + + throw new UnsupportedException(sprintf('Cannot find any resolver for "%s" type.', $subjectType), $subject); + } + + /** @param TypeResolverInterface $resolver */ + $resolver = $this->resolvers->get($subjectType); + + return $resolver->resolve($subject, $typeContext); + } + + public static function create(): self + { + $resolvers = new class() implements ContainerInterface { + private readonly array $resolvers; + + public function __construct() + { + $stringTypeResolver = class_exists(PhpDocParser::class) ? new StringTypeResolver() : null; + $typeContextFactory = new TypeContextFactory($stringTypeResolver); + $reflectionTypeResolver = new ReflectionTypeResolver(); + + $resolvers = [ + \ReflectionType::class => $reflectionTypeResolver, + \ReflectionParameter::class => new ReflectionParameterTypeResolver($reflectionTypeResolver, $typeContextFactory), + \ReflectionProperty::class => new ReflectionPropertyTypeResolver($reflectionTypeResolver, $typeContextFactory), + \ReflectionFunctionAbstract::class => new ReflectionReturnTypeResolver($reflectionTypeResolver, $typeContextFactory), + ]; + + if (null !== $stringTypeResolver) { + $resolvers['string'] = $stringTypeResolver; + } + + $this->resolvers = $resolvers; + } + + public function has(string $id): bool + { + return isset($this->resolvers[$id]); + } + + public function get(string $id): TypeResolverInterface + { + return $this->resolvers[$id]; + } + }; + + return new self($resolvers); + } +} diff --git a/src/Symfony/Component/TypeInfo/TypeResolver/TypeResolverInterface.php b/src/Symfony/Component/TypeInfo/TypeResolver/TypeResolverInterface.php new file mode 100644 index 0000000000000..aca3f26623491 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/TypeResolver/TypeResolverInterface.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo\TypeResolver; + +use Symfony\Component\TypeInfo\Exception\UnsupportedException; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeContext\TypeContext; + +/** + * Resolves type for a given subject. + * + * @author Mathias Arlaud + * @author Baptiste Leduc + */ +interface TypeResolverInterface +{ + /** + * Try to resolve a {@see Type} on a $subject. + * If the resolver cannot resolve the type, it will throw a {@see UnsupportedException}. + * + * @throws UnsupportedException + */ + public function resolve(mixed $subject, TypeContext $typeContext = null): Type; +} diff --git a/src/Symfony/Component/TypeInfo/composer.json b/src/Symfony/Component/TypeInfo/composer.json new file mode 100644 index 0000000000000..2967e0d1ed79d --- /dev/null +++ b/src/Symfony/Component/TypeInfo/composer.json @@ -0,0 +1,42 @@ +{ + "name": "symfony/type-info", + "type": "library", + "description": "Extracts PHP types information.", + "keywords": [ + "type", + "phpdoc", + "phpstan", + "symfony" + ], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Baptiste LEDUC", + "email": "baptiste.leduc@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=8.2", + "psr/container": "^1.1|^2.0" + }, + "require-dev": { + "symfony/dependency-injection": "^7.1", + "phpstan/phpdoc-parser": "^1.0" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\TypeInfo\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/TypeInfo/phpunit.xml.dist b/src/Symfony/Component/TypeInfo/phpunit.xml.dist new file mode 100644 index 0000000000000..11b4d18ad464c --- /dev/null +++ b/src/Symfony/Component/TypeInfo/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + + ./Resources + ./Tests + ./vendor + + +