diff --git a/composer.json b/composer.json index 381c2fef9c772..18787ebeadb9f 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/CHANGELOG.md b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md index ed3b53d14ac42..fc5b36b43bf1b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +7.1 +--- + + * Register TypeInfo services + 7.0 --- diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php index 1d21c6b663688..3f90c740f330c 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php @@ -69,6 +69,7 @@ class UnusedTagsPass implements CompilerPassInterface 'mime.mime_type_guesser', 'monolog.logger', 'notifier.channel', + 'type_info.resolver', 'property_info.access_extractor', 'property_info.initializable_extractor', 'property_info.list_extractor', diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index 883b2da90178a..cbc89e2d869ee 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -43,6 +43,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; @@ -158,6 +159,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); @@ -1148,6 +1150,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 d03932f8c4840..498165ec40d2d 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -167,6 +167,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 +390,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); } @@ -1950,6 +1956,22 @@ 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) + ->addTag('type_info.resolver', ['priority' => -1000]); + + $container->setAlias(StringTypeResolver::class, '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/FrameworkBundle.php b/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php index 25f9637867943..1234c420d1861 100644 --- a/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php +++ b/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php @@ -64,6 +64,7 @@ use Symfony\Component\Translation\DependencyInjection\TranslationExtractorPass; use Symfony\Component\Translation\DependencyInjection\TranslatorPass; use Symfony\Component\Translation\DependencyInjection\TranslatorPathsPass; +use Symfony\Component\TypeInfo\DependencyInjection\TypeInfoPass; use Symfony\Component\Validator\DependencyInjection\AddAutoMappingConfigurationPass; use Symfony\Component\Validator\DependencyInjection\AddConstraintValidatorsPass; use Symfony\Component\Validator\DependencyInjection\AddValidatorInitializersPass; @@ -150,6 +151,7 @@ public function build(ContainerBuilder $container): void $this->addCompilerPassIfExists($container, TranslationDumperPass::class); $container->addCompilerPass(new FragmentRendererPass()); $this->addCompilerPassIfExists($container, SerializerPass::class); + $this->addCompilerPassIfExists($container, TypeInfoPass::class); $this->addCompilerPassIfExists($container, PropertyInfoPass::class); $container->addCompilerPass(new ControllerArgumentValueResolverPass()); $container->addCompilerPass(new CachePoolPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 32); 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 532cf022d3c66..30da597ee642f 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 @@ + @@ -326,6 +327,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..164372c3d2086 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/type_info.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\DependencyInjection\Loader\Configurator; + +use Symfony\Component\TypeInfo\TypeContext\TypeContextFactory; +use Symfony\Component\TypeInfo\TypeResolver\ChainTypeResolver; +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\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', ChainTypeResolver::class) + ->args([[]]) + ->alias(TypeResolverInterface::class, 'type_info.resolver') + + ->set('type_info.resolver.reflection_type', ReflectionTypeResolver::class) + ->args([service('type_info.type_context_factory')]) + ->tag('type_info.resolver', ['priority' => -1001]) + ->alias(ReflectionTypeResolver::class, 'type_info.resolver.reflection_type') + + ->set('type_info.resolver.reflection_parameter', ReflectionParameterTypeResolver::class) + ->args([service('type_info.resolver.reflection_type'), service('type_info.type_context_factory')]) + ->tag('type_info.resolver', ['priority' => -1002]) + ->alias(ReflectionParameterTypeResolver::class, 'type_info.resolver.reflection_parameter') + + ->set('type_info.resolver.reflection_property', ReflectionPropertyTypeResolver::class) + ->args([service('type_info.resolver.reflection_type'), service('type_info.type_context_factory')]) + ->tag('type_info.resolver', ['priority' => -1003]) + ->alias(ReflectionPropertyTypeResolver::class, 'type_info.resolver.reflection_property') + + ->set('type_info.resolver.reflection_return', ReflectionReturnTypeResolver::class) + ->args([service('type_info.resolver.reflection_type'), service('type_info.type_context_factory')]) + ->tag('type_info.resolver', ['priority' => -1004]) + ->alias(ReflectionReturnTypeResolver::class, 'type_info.resolver.reflection_return') + ; +}; diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php index fa2de05a0d18e..3f5b95f8a15c7 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 @@ -623,6 +624,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 9036fc6d58da7..3597cd99f8e10 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php @@ -1608,6 +1608,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..de4f1cf6067bd --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/TypeInfoTest.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\Bundle\FrameworkBundle\Tests\Functional; + +use Symfony\Component\TypeInfo\Type; + +class TypeInfoTest extends AbstractWebTestCase +{ + public function testComponent() + { + static::bootKernel(['test_case' => 'TypeInfo']); + + $this->assertEquals(Type::union(Type::int(), Type::null()), static::getContainer()->get('type_info.resolver')->resolve('int|null')); + } +} 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 8e879fd213adc..14842532083dc 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/Config/Resource/FileExistenceResource.php b/src/Symfony/Component/Config/Resource/FileExistenceResource.php index e7b91ff382bb2..666866ee42f77 100644 --- a/src/Symfony/Component/Config/Resource/FileExistenceResource.php +++ b/src/Symfony/Component/Config/Resource/FileExistenceResource.php @@ -38,7 +38,7 @@ public function __construct(string $resource) public function __toString(): string { - return $this->resource; + return 'existence.'.$this->resource; } public function getResource(): string diff --git a/src/Symfony/Component/Config/Tests/Resource/FileExistenceResourceTest.php b/src/Symfony/Component/Config/Tests/Resource/FileExistenceResourceTest.php index 31fd7846d81ca..b719099f804dc 100644 --- a/src/Symfony/Component/Config/Tests/Resource/FileExistenceResourceTest.php +++ b/src/Symfony/Component/Config/Tests/Resource/FileExistenceResourceTest.php @@ -36,7 +36,7 @@ protected function tearDown(): void public function testToString() { - $this->assertSame($this->file, (string) $this->resource); + $this->assertSame('existence.'.$this->file, (string) $this->resource); } public function testGetResource() diff --git a/src/Symfony/Component/HttpKernel/Controller/ControllerResolver.php b/src/Symfony/Component/HttpKernel/Controller/ControllerResolver.php index d39508949215a..8d3255d4678b7 100644 --- a/src/Symfony/Component/HttpKernel/Controller/ControllerResolver.php +++ b/src/Symfony/Component/HttpKernel/Controller/ControllerResolver.php @@ -268,12 +268,6 @@ private function checkController(Request $request, callable $controller): callab $name = preg_replace_callback('/[a-zA-Z_\x7f-\xff][\\\\a-zA-Z0-9_\x7f-\xff]*+@anonymous\x00.*?\.php(?:0x?|:[0-9]++\$)[0-9a-fA-F]++/', fn ($m) => class_exists($m[0], false) ? (get_parent_class($m[0]) ?: key(class_implements($m[0])) ?: 'class').'@anonymous' : $m[0], $name); } - if (-1 === $request->attributes->get('_check_controller_is_allowed')) { - trigger_deprecation('symfony/http-kernel', '6.4', 'Callable "%s()" is not allowed as a controller. Did you miss tagging it with "#[AsController]" or registering its type with "%s::allowControllers()"?', $name, self::class); - - return $controller; - } - throw new BadRequestException(sprintf('Callable "%s()" is not allowed as a controller. Did you miss tagging it with "#[AsController]" or registering its type with "%s::allowControllers()"?', $name, self::class)); } } diff --git a/src/Symfony/Component/HttpKernel/DataCollector/LoggerDataCollector.php b/src/Symfony/Component/HttpKernel/DataCollector/LoggerDataCollector.php index c60d35e53d36d..eb2b9c85ca061 100644 --- a/src/Symfony/Component/HttpKernel/DataCollector/LoggerDataCollector.php +++ b/src/Symfony/Component/HttpKernel/DataCollector/LoggerDataCollector.php @@ -201,7 +201,7 @@ private function getContainerDeprecationLogs(): array private function getContainerCompilerLogs(string $compilerLogsFilepath = null): array { - if (!is_file($compilerLogsFilepath)) { + if (!$compilerLogsFilepath || !is_file($compilerLogsFilepath)) { return []; } diff --git a/src/Symfony/Component/HttpKernel/EventListener/FragmentListener.php b/src/Symfony/Component/HttpKernel/EventListener/FragmentListener.php index 562244b338b51..4aa246b6a4967 100644 --- a/src/Symfony/Component/HttpKernel/EventListener/FragmentListener.php +++ b/src/Symfony/Component/HttpKernel/EventListener/FragmentListener.php @@ -70,7 +70,7 @@ public function onKernelRequest(RequestEvent $event): void } parse_str($request->query->get('_path', ''), $attributes); - $attributes['_check_controller_is_allowed'] = -1; // @deprecated, switch to true in Symfony 7 + $attributes['_check_controller_is_allowed'] = true; $request->attributes->add($attributes); $request->attributes->set('_route_params', array_replace($request->attributes->get('_route_params', []), $attributes)); $request->query->remove('_path'); diff --git a/src/Symfony/Component/HttpKernel/Fragment/AbstractSurrogateFragmentRenderer.php b/src/Symfony/Component/HttpKernel/Fragment/AbstractSurrogateFragmentRenderer.php index 668be81e8c5cb..14a3f3cf24133 100644 --- a/src/Symfony/Component/HttpKernel/Fragment/AbstractSurrogateFragmentRenderer.php +++ b/src/Symfony/Component/HttpKernel/Fragment/AbstractSurrogateFragmentRenderer.php @@ -59,6 +59,8 @@ public function __construct(?SurrogateInterface $surrogate, FragmentRendererInte public function render(string|ControllerReference $uri, Request $request, array $options = []): Response { if (!$this->surrogate || !$this->surrogate->hasSurrogateCapability($request)) { + $request->attributes->set('_check_controller_is_allowed', true); + if ($uri instanceof ControllerReference && $this->containsNonScalars($uri->attributes)) { throw new \InvalidArgumentException('Passing non-scalar values as part of URI attributes to the ESI and SSI rendering strategies is not supported. Use a different rendering strategy or pass scalar values.'); } diff --git a/src/Symfony/Component/HttpKernel/Fragment/InlineFragmentRenderer.php b/src/Symfony/Component/HttpKernel/Fragment/InlineFragmentRenderer.php index c74200af6d408..6b815a87ba91a 100644 --- a/src/Symfony/Component/HttpKernel/Fragment/InlineFragmentRenderer.php +++ b/src/Symfony/Component/HttpKernel/Fragment/InlineFragmentRenderer.php @@ -130,6 +130,9 @@ protected function createSubRequest(string $uri, Request $request): Request if ($request->attributes->has('_stateless')) { $subRequest->attributes->set('_stateless', $request->attributes->get('_stateless')); } + if ($request->attributes->has('_check_controller_is_allowed')) { + $subRequest->attributes->set('_check_controller_is_allowed', $request->attributes->get('_check_controller_is_allowed')); + } return $subRequest; } diff --git a/src/Symfony/Component/HttpKernel/Log/DebugLoggerConfigurator.php b/src/Symfony/Component/HttpKernel/Log/DebugLoggerConfigurator.php index 4271244ad97d5..537c1004083f4 100644 --- a/src/Symfony/Component/HttpKernel/Log/DebugLoggerConfigurator.php +++ b/src/Symfony/Component/HttpKernel/Log/DebugLoggerConfigurator.php @@ -18,12 +18,12 @@ */ class DebugLoggerConfigurator { - private ?\Closure $processor = null; + private ?object $processor = null; public function __construct(callable $processor, bool $enable = null) { if ($enable ?? !\in_array(\PHP_SAPI, ['cli', 'phpdbg', 'embed'], true)) { - $this->processor = $processor(...); + $this->processor = \is_object($processor) ? $processor : $processor(...); } } diff --git a/src/Symfony/Component/Ldap/Adapter/ExtLdap/Query.php b/src/Symfony/Component/Ldap/Adapter/ExtLdap/Query.php index 58d716943712a..b18b2b3585855 100644 --- a/src/Symfony/Component/Ldap/Adapter/ExtLdap/Query.php +++ b/src/Symfony/Component/Ldap/Adapter/ExtLdap/Query.php @@ -107,7 +107,7 @@ public function execute(): CollectionInterface $this->resetPagination(); } - throw new LdapException(sprintf('Could not complete search with dn "%s", query "%s" and filters "%s".%s.', $this->dn, $this->query, implode(',', $this->options['filter']), $ldapError)); + throw new LdapException(sprintf('Could not complete search with dn "%s", query "%s" and filters "%s".%s.', $this->dn, $this->query, implode(',', $this->options['filter']), $ldapError), $errno); } $this->results[] = $search; diff --git a/src/Symfony/Component/String/AbstractString.php b/src/Symfony/Component/String/AbstractString.php index 3f36eefceef0d..10231a217487c 100644 --- a/src/Symfony/Component/String/AbstractString.php +++ b/src/Symfony/Component/String/AbstractString.php @@ -507,20 +507,14 @@ public function toByteString(string $toEncoding = null): ByteString return $b; } - set_error_handler(static fn ($t, $m) => throw new InvalidArgumentException($m)); - try { - try { - $b->string = mb_convert_encoding($this->string, $toEncoding, 'UTF-8'); - } catch (InvalidArgumentException $e) { - if (!\function_exists('iconv')) { - throw $e; - } - - $b->string = iconv('UTF-8', $toEncoding, $this->string); + $b->string = mb_convert_encoding($this->string, $toEncoding, 'UTF-8'); + } catch (\ValueError $e) { + if (!\function_exists('iconv')) { + throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e); } - } finally { - restore_error_handler(); + + $b->string = iconv('UTF-8', $toEncoding, $this->string); } return $b; diff --git a/src/Symfony/Component/String/Tests/AbstractAsciiTestCase.php b/src/Symfony/Component/String/Tests/AbstractAsciiTestCase.php index 5db9da5753a0c..b4578c77802fa 100644 --- a/src/Symfony/Component/String/Tests/AbstractAsciiTestCase.php +++ b/src/Symfony/Component/String/Tests/AbstractAsciiTestCase.php @@ -1581,4 +1581,22 @@ public static function provideWidth(): array [17, "\u{007f}\u{007f}f\u{001b}[0moo\u{0001}bar\u{007f}cccïf\u{008e}cy\u{0005}1", false], // f[0moobarcccïfcy1 ]; } + + /** + * @dataProvider provideToByteString + */ + public function testToByteString(string $origin, string $encoding) + { + $instance = static::createFromString($origin)->toByteString($encoding); + $this->assertInstanceOf(ByteString::class, $instance); + } + + public static function provideToByteString(): array + { + return [ + ['žsžsý', 'UTF-8'], + ['žsžsý', 'windows-1250'], + ['žsžsý', 'Windows-1252'], + ]; + } } 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/BuiltinType.php b/src/Symfony/Component/TypeInfo/BuiltinType.php new file mode 100644 index 0000000000000..783d2d5b34876 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/BuiltinType.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; + +/** + * @author Mathias Arlaud + * @author Baptiste Leduc + */ +enum BuiltinType: 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'; +} 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/DependencyInjection/TypeInfoPass.php b/src/Symfony/Component/TypeInfo/DependencyInjection/TypeInfoPass.php new file mode 100644 index 0000000000000..c1e061b46a5bf --- /dev/null +++ b/src/Symfony/Component/TypeInfo/DependencyInjection/TypeInfoPass.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo\DependencyInjection; + +use Symfony\Component\DependencyInjection\Argument\IteratorArgument; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\Compiler\PriorityTaggedServiceTrait; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +/** + * Adds resolvers to the type_info service. + * + * @author Mathias Arlaud + * @author Baptiste Leduc + */ +class TypeInfoPass implements CompilerPassInterface +{ + use PriorityTaggedServiceTrait; + + public function process(ContainerBuilder $container): void + { + if (!$container->hasDefinition('type_info.resolver')) { + return; + } + + $definition = $container->getDefinition('type_info.resolver'); + $resolvers = $this->findAndSortTaggedServices('type_info.resolver', $container); + + $definition->replaceArgument(0, new IteratorArgument($resolvers)); + } +} 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..8e9e2e8b5fdc4 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Exception/UnsupportedException.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 UnsupportedException extends \LogicException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/TypeInfo/LICENSE b/src/Symfony/Component/TypeInfo/LICENSE new file mode 100644 index 0000000000000..3ed9f412ce53d --- /dev/null +++ b/src/Symfony/Component/TypeInfo/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2023-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..2392ce0cbfe1a --- /dev/null +++ b/src/Symfony/Component/TypeInfo/README.md @@ -0,0 +1,14 @@ +TypeInfo Component +================== + +The TypeInfo component extracts information about PHP types using +metadata of popular sources. + +Resources +--------- + + * [Documentation](https://symfony.com/doc/current/components/type_info.html) + * [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/DependencyInjection/TypeInfoPassTest.php b/src/Symfony/Component/TypeInfo/Tests/DependencyInjection/TypeInfoPassTest.php new file mode 100644 index 0000000000000..e11087f8a9a8b --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/DependencyInjection/TypeInfoPassTest.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo\Tests\DependencyInjection; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\DependencyInjection\Argument\IteratorArgument; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\TypeInfo\DependencyInjection\TypeInfoPass; + +class TypeInfoPassTest extends TestCase +{ + public function testInjectTypeResolvers() + { + $container = new ContainerBuilder(); + + $container->register('type_info.resolver')->setArguments([null]); + + $container->register('second')->addTag('type_info.resolver', ['priority' => 10]); + $container->register('third')->addTag('type_info.resolver', ['priority' => 1]); + $container->register('first')->addTag('type_info.resolver', ['priority' => 100]); + + (new TypeInfoPass())->process($container); + + $this->assertEquals( + new IteratorArgument([new Reference('first'), new Reference('second'), new Reference('third')]), + $container->getDefinition('type_info.resolver')->getArgument(0), + ); + } +} 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..7b4c8434d6634 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/Fixtures/ReflectionExtractableDummy.php @@ -0,0 +1,84 @@ +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 getVoid(): void + { + } + + public function getNever(): never + { + exit(0); + } + + 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; + } +} 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..1d7f9d3700912 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/Type/BackedEnumTypeTest.php @@ -0,0 +1,47 @@ + + * + * 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\BuiltinType as BuiltinTypeEnum; +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\BackedEnumType; +use Symfony\Component\TypeInfo\Type\BuiltinType; + +class BackedEnumTypeTest extends TestCase +{ + public function testCannotCreateWithInvalidClass() + { + $this->expectException(InvalidArgumentException::class); + new BackedEnumType(DummyEnum::class, new BuiltinType(BuiltinTypeEnum::INT)); + } + + public function testCannotCreateWithInvalidBackingType() + { + $this->expectException(InvalidArgumentException::class); + new BackedEnumType(DummyBackedEnum::class, new BuiltinType(BuiltinTypeEnum::BOOL)); + } + + public function testToString() + { + $this->assertSame(DummyBackedEnum::class, (string) new BackedEnumType(DummyBackedEnum::class, new BuiltinType(BuiltinTypeEnum::INT))); + } + + public function testIsBuiltinType() + { + $this->assertFalse((new BackedEnumType(DummyBackedEnum::class, new BuiltinType(BuiltinTypeEnum::INT)))->isNullable()); + $this->assertFalse((new BackedEnumType(DummyBackedEnum::class, new BuiltinType(BuiltinTypeEnum::INT)))->isBuiltinType(BuiltinTypeEnum::ARRAY)); + $this->assertTrue((new BackedEnumType(DummyBackedEnum::class, new BuiltinType(BuiltinTypeEnum::INT)))->isBuiltinType(BuiltinTypeEnum::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..19d310949b71d --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/Type/BuiltinTypeTest.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\Tests\Type; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\TypeInfo\BuiltinType as BuiltinTypeEnum; +use Symfony\Component\TypeInfo\Type\BuiltinType; + +class BuiltinTypeTest extends TestCase +{ + public function testToString() + { + $this->assertSame('int', (string) new BuiltinType(BuiltinTypeEnum::INT)); + } + + public function testIsBuiltinType() + { + $this->assertFalse((new BuiltinType(BuiltinTypeEnum::INT))->isBuiltinType(BuiltinTypeEnum::ARRAY)); + $this->assertTrue((new BuiltinType(BuiltinTypeEnum::INT))->isBuiltinType(BuiltinTypeEnum::INT)); + $this->assertFalse((new BuiltinType(BuiltinTypeEnum::INT))->isNullable()); + $this->assertTrue((new BuiltinType(BuiltinTypeEnum::NULL))->isNullable()); + $this->assertTrue((new BuiltinType(BuiltinTypeEnum::MIXED))->isNullable()); + } +} 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..1ad45ca546f89 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/Type/CollectionTypeTest.php @@ -0,0 +1,84 @@ + + * + * 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\BuiltinType as BuiltinTypeEnum; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\GenericType; + +class CollectionTypeTest extends TestCase +{ + public function testGetCollectionKeyType() + { + $type = new CollectionType(new BuiltinType(BuiltinTypeEnum::ARRAY)); + $this->assertEquals(Type::union(Type::int(), Type::string()), $type->getCollectionKeyType()); + + $type = new CollectionType(new GenericType(new BuiltinType(BuiltinTypeEnum::ARRAY), new BuiltinType(BuiltinTypeEnum::BOOL))); + $this->assertEquals(Type::int(), $type->getCollectionKeyType()); + + $type = new CollectionType(new GenericType( + new BuiltinType(BuiltinTypeEnum::ARRAY), + new BuiltinType(BuiltinTypeEnum::STRING), + new BuiltinType(BuiltinTypeEnum::BOOL), + )); + $this->assertEquals(Type::string(), $type->getCollectionKeyType()); + } + + public function testGetCollectionValueType() + { + $type = new CollectionType(new BuiltinType(BuiltinTypeEnum::ARRAY)); + $this->assertEquals(Type::mixed(), $type->getCollectionValueType()); + + $type = new CollectionType(new GenericType(new BuiltinType(BuiltinTypeEnum::ARRAY), new BuiltinType(BuiltinTypeEnum::BOOL))); + $this->assertEquals(Type::bool(), $type->getCollectionValueType()); + + $type = new CollectionType(new GenericType( + new BuiltinType(BuiltinTypeEnum::ARRAY), + new BuiltinType(BuiltinTypeEnum::STRING), + new BuiltinType(BuiltinTypeEnum::BOOL), + )); + $this->assertEquals(Type::bool(), $type->getCollectionValueType()); + } + + public function testToString() + { + $type = new CollectionType(new BuiltinType(BuiltinTypeEnum::ITERABLE)); + $this->assertEquals('iterable', (string) $type); + + $type = new CollectionType(new GenericType(new BuiltinType(BuiltinTypeEnum::ARRAY), new BuiltinType(BuiltinTypeEnum::BOOL))); + $this->assertEquals('array', (string) $type); + + $type = new CollectionType(new GenericType( + new BuiltinType(BuiltinTypeEnum::ARRAY), + new BuiltinType(BuiltinTypeEnum::STRING), + new BuiltinType(BuiltinTypeEnum::BOOL), + )); + $this->assertEquals('array', (string) $type); + } + + public function testIsBuiltinType() + { + $type = new CollectionType(new GenericType( + new BuiltinType(BuiltinTypeEnum::ARRAY), + new BuiltinType(BuiltinTypeEnum::STRING), + new BuiltinType(BuiltinTypeEnum::BOOL), + )); + + $this->assertTrue($type->isBuiltinType(BuiltinTypeEnum::ARRAY)); + $this->assertFalse($type->isBuiltinType(BuiltinTypeEnum::STRING)); + $this->assertFalse($type->isBuiltinType(BuiltinTypeEnum::INT)); + $this->assertFalse($type->isNullable()); + } +} 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..ba114bc31bc02 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/Type/EnumTypeTest.php @@ -0,0 +1,39 @@ + + * + * 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\BuiltinType as BuiltinTypeEnum; +use Symfony\Component\TypeInfo\Exception\InvalidArgumentException; +use Symfony\Component\TypeInfo\Tests\Fixtures\DummyEnum; +use Symfony\Component\TypeInfo\Type\EnumType; + +class EnumTypeTest extends TestCase +{ + public function testCannotCreateWithInvalidClass() + { + $this->expectException(InvalidArgumentException::class); + new EnumType(self::class); + } + + public function testToString() + { + $this->assertSame(DummyEnum::class, (string) new EnumType(DummyEnum::class)); + } + + public function testIsBuiltinType() + { + $this->assertFalse((new EnumType(DummyEnum::class))->isNullable()); + $this->assertFalse((new EnumType(DummyEnum::class))->isBuiltinType(BuiltinTypeEnum::ARRAY)); + $this->assertFalse((new EnumType(DummyEnum::class))->isBuiltinType(BuiltinTypeEnum::INT)); + } +} 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..59f77c0bb30fa --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/Type/GenericTypeTest.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\Type; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\TypeInfo\BuiltinType as BuiltinTypeEnum; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\Type\GenericType; +use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\Type\UnionType; + +class GenericTypeTest extends TestCase +{ + public function testToString() + { + $type = new GenericType(new BuiltinType(BuiltinTypeEnum::ARRAY), new BuiltinType(BuiltinTypeEnum::BOOL)); + $this->assertEquals('array', (string) $type); + + $type = new GenericType( + new BuiltinType(BuiltinTypeEnum::ARRAY), + new BuiltinType(BuiltinTypeEnum::STRING), + new BuiltinType(BuiltinTypeEnum::BOOL), + ); + $this->assertEquals('array', (string) $type); + + $type = new GenericType( + new ObjectType(self::class), + new UnionType(new BuiltinType(BuiltinTypeEnum::BOOL), new BuiltinType(BuiltinTypeEnum::STRING)), + new BuiltinType(BuiltinTypeEnum::INT), + new BuiltinType(BuiltinTypeEnum::FLOAT), + ); + $this->assertEquals(sprintf('%s', self::class), (string) $type); + } + + public function testIsBuiltinType() + { + $type = new GenericType( + new BuiltinType(BuiltinTypeEnum::ARRAY), + new BuiltinType(BuiltinTypeEnum::STRING), + new BuiltinType(BuiltinTypeEnum::BOOL), + ); + $this->assertFalse($type->isNullable()); + $this->assertTrue($type->isBuiltinType(BuiltinTypeEnum::ARRAY)); + $this->assertFalse($type->isBuiltinType(BuiltinTypeEnum::STRING)); + + $type = new GenericType( + new ObjectType(self::class), + new UnionType(new BuiltinType(BuiltinTypeEnum::BOOL), new BuiltinType(BuiltinTypeEnum::STRING)), + new BuiltinType(BuiltinTypeEnum::INT), + new BuiltinType(BuiltinTypeEnum::FLOAT), + ); + $this->assertFalse($type->isNullable()); + $this->assertTrue($type->isBuiltinType(BuiltinTypeEnum::OBJECT)); + $this->assertFalse($type->isBuiltinType(BuiltinTypeEnum::INT)); + $this->assertFalse($type->isBuiltinType(BuiltinTypeEnum::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..6321b0c12331d --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/Type/IntersectionTypeTest.php @@ -0,0 +1,77 @@ + + * + * 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\BuiltinType as BuiltinTypeEnum; +use Symfony\Component\TypeInfo\Exception\InvalidArgumentException; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\Type\IntersectionType; + +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 testIsBuiltinType() + { + $type = new IntersectionType(Type::int(), Type::string(), Type::float()); + $this->assertFalse($type->isNullable()); + $this->assertFalse($type->isBuiltinType(BuiltinTypeEnum::ARRAY)); + + $type = new IntersectionType(Type::int(), Type::string(), Type::union(Type::float(), Type::bool())); + $this->assertFalse($type->isNullable()); + $this->assertFalse($type->isBuiltinType(BuiltinTypeEnum::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..ea1a128e95832 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/Type/ObjectTypeTest.php @@ -0,0 +1,31 @@ + + * + * 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\BuiltinType as BuiltinTypeEnum; +use Symfony\Component\TypeInfo\Type\ObjectType; + +class ObjectTypeTest extends TestCase +{ + public function testToString() + { + $this->assertSame(self::class, (string) new ObjectType(self::class)); + } + + public function testIsBuiltinType() + { + $this->assertFalse((new ObjectType(self::class))->isNullable()); + $this->assertFalse((new ObjectType(self::class))->isBuiltinType(BuiltinTypeEnum::ARRAY)); + $this->assertTrue((new ObjectType(self::class))->isBuiltinType(BuiltinTypeEnum::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..fc1f7337e7944 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/Type/UnionTypeTest.php @@ -0,0 +1,95 @@ + + * + * 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\BuiltinType as BuiltinTypeEnum; +use Symfony\Component\TypeInfo\Exception\InvalidArgumentException; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\Type\UnionType; + +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()); + } + + 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 testIsBuiltinType() + { + $type = new UnionType(Type::int(), Type::string(), Type::float()); + $this->assertFalse($type->isNullable()); + $this->assertFalse($type->isBuiltinType(BuiltinTypeEnum::ARRAY)); + + $type = new UnionType(Type::int(), Type::string(), Type::intersection(Type::float(), Type::bool())); + $this->assertFalse($type->isNullable()); + $this->assertTrue($type->isBuiltinType(BuiltinTypeEnum::INT)); + $this->assertTrue($type->isBuiltinType(BuiltinTypeEnum::STRING)); + $this->assertFalse($type->isBuiltinType(BuiltinTypeEnum::FLOAT)); + $this->assertFalse($type->isBuiltinType(BuiltinTypeEnum::BOOL)); + } +} 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..f031b04c889ce --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/TypeContext/TypeContextFactoryTest.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\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 testCollectClassTemplates() + { + $this->assertEquals([], $this->typeContextFactory->createFromClassName(Dummy::class)->classTemplates); + + $templates = [ + ['name' => 'T', 'type' => Type::union(Type::int(), Type::string())], + ['name' => 'U', 'type' => null], + ]; + + $this->assertEquals($templates, $this->typeContextFactory->createFromClassName(DummyWithTemplates::class)->classTemplates); + + $this->assertEquals($templates, $this->typeContextFactory->createFromReflection(new \ReflectionClass(DummyWithTemplates::class))->classTemplates); + $this->assertEquals($templates, $this->typeContextFactory->createFromReflection(new \ReflectionProperty(DummyWithTemplates::class, 'price'))->classTemplates); + $this->assertEquals($templates, $this->typeContextFactory->createFromReflection(new \ReflectionMethod(DummyWithTemplates::class, 'getPrice'))->classTemplates); + $this->assertEquals($templates, $this->typeContextFactory->createFromReflection(new \ReflectionParameter([DummyWithTemplates::class, 'getPrice'], 'inCents'))->classTemplates); + } + + public function testDoNotCollectClassTemplatesWhenToStringTypeResolver() + { + $typeContextFactory = new TypeContextFactory(); + + $this->assertEquals([], $typeContextFactory->createFromClassName(DummyWithTemplates::class)->classTemplates); + } +} 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..b0e7bc7ef91a1 --- /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 testResolve() + { + $typeContext = (new TypeContextFactory())->createFromClassName(DummyWithUses::class); + + $this->assertSame(DummyWithUses::class, $typeContext->resolve('DummyWithUses')); + $this->assertSame(Type::class, $typeContext->resolve('Type')); + $this->assertSame(\DateTimeImmutable::class, $typeContext->resolve('DateTime')); + $this->assertSame('Symfony\\Component\\TypeInfo\\Tests\\Fixtures\\unknown', $typeContext->resolve('unknown')); + $this->assertSame('unknown', $typeContext->resolve('\\unknown')); + + $typeContextWithoutNamespace = new TypeContext('Foo', 'Bar'); + $this->assertSame('unknown', $typeContextWithoutNamespace->resolve('unknown')); + } + + public function testResolveDeclaringClass() + { + $this->assertSame(Dummy::class, (new TypeContextFactory())->createFromClassName(Dummy::class)->resolveDeclaringClass()); + $this->assertSame(AbstractDummy::class, (new TypeContextFactory())->createFromClassName(Dummy::class, AbstractDummy::class)->resolveDeclaringClass()); + } + + public function testResolveCalledClass() + { + $this->assertSame(Dummy::class, (new TypeContextFactory())->createFromClassName(Dummy::class)->resolveCalledClass()); + $this->assertSame(Dummy::class, (new TypeContextFactory())->createFromClassName(Dummy::class, AbstractDummy::class)->resolveCalledClass()); + } + + public function testResolveParentClass() + { + $this->assertSame(AbstractDummy::class, (new TypeContextFactory())->createFromClassName(Dummy::class)->resolveParentClass()); + $this->assertSame(\stdClass::class, (new TypeContextFactory())->createFromClassName(DummyExtendingStdClass::class)->resolveParentClass()); + } + + public function testCannotResolveParentClassWhenDoNotInherit() + { + $this->expectException(LogicException::class); + (new TypeContextFactory())->createFromClassName(AbstractDummy::class)->resolveParentClass(); + } +} diff --git a/src/Symfony/Component/TypeInfo/Tests/TypeFactoryTest.php b/src/Symfony/Component/TypeInfo/Tests/TypeFactoryTest.php new file mode 100644 index 0000000000000..f9675d6b5d7bb --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/TypeFactoryTest.php @@ -0,0 +1,193 @@ + + * + * 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\BuiltinType as BuiltinTypeEnum; +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; + +class TypeFactoryTest extends TestCase +{ + public function testCreateBuiltin() + { + $this->assertEquals(new BuiltinType(BuiltinTypeEnum::INT), Type::builtin(BuiltinTypeEnum::INT)); + $this->assertEquals(new BuiltinType(BuiltinTypeEnum::INT), Type::builtin('int')); + $this->assertEquals(new BuiltinType(BuiltinTypeEnum::INT), Type::int()); + $this->assertEquals(new BuiltinType(BuiltinTypeEnum::FLOAT), Type::float()); + $this->assertEquals(new BuiltinType(BuiltinTypeEnum::STRING), Type::string()); + $this->assertEquals(new BuiltinType(BuiltinTypeEnum::BOOL), Type::bool()); + $this->assertEquals(new BuiltinType(BuiltinTypeEnum::RESOURCE), Type::resource()); + $this->assertEquals(new BuiltinType(BuiltinTypeEnum::FALSE), Type::false()); + $this->assertEquals(new BuiltinType(BuiltinTypeEnum::TRUE), Type::true()); + $this->assertEquals(new BuiltinType(BuiltinTypeEnum::CALLABLE), Type::callable()); + $this->assertEquals(new BuiltinType(BuiltinTypeEnum::NULL), Type::null()); + $this->assertEquals(new BuiltinType(BuiltinTypeEnum::MIXED), Type::mixed()); + } + + public function testCreateArray() + { + $this->assertEquals(new CollectionType(new BuiltinType(BuiltinTypeEnum::ARRAY)), Type::array()); + + $this->assertEquals( + new CollectionType(new GenericType( + new BuiltinType(BuiltinTypeEnum::ARRAY), + new UnionType(new BuiltinType(BuiltinTypeEnum::INT), new BuiltinType(BuiltinTypeEnum::STRING)), + new BuiltinType(BuiltinTypeEnum::BOOL), + )), + Type::array(Type::bool()), + ); + + $this->assertEquals( + new CollectionType(new GenericType( + new BuiltinType(BuiltinTypeEnum::ARRAY), + new BuiltinType(BuiltinTypeEnum::STRING), + new BuiltinType(BuiltinTypeEnum::BOOL), + )), + Type::array(Type::bool(), Type::string()), + ); + + $this->assertEquals( + new CollectionType(new GenericType( + new BuiltinType(BuiltinTypeEnum::ARRAY), + new BuiltinType(BuiltinTypeEnum::INT), + new BuiltinType(BuiltinTypeEnum::MIXED), + )), + Type::list(), + ); + + $this->assertEquals( + new CollectionType(new GenericType( + new BuiltinType(BuiltinTypeEnum::ARRAY), + new BuiltinType(BuiltinTypeEnum::INT), + new BuiltinType(BuiltinTypeEnum::BOOL), + )), + Type::list(Type::bool()), + ); + + $this->assertEquals( + new CollectionType(new GenericType( + new BuiltinType(BuiltinTypeEnum::ARRAY), + new BuiltinType(BuiltinTypeEnum::STRING), + new BuiltinType(BuiltinTypeEnum::MIXED), + )), + Type::dict(), + ); + + $this->assertEquals( + new CollectionType(new GenericType( + new BuiltinType(BuiltinTypeEnum::ARRAY), + new BuiltinType(BuiltinTypeEnum::STRING), + new BuiltinType(BuiltinTypeEnum::BOOL), + )), + Type::dict(Type::bool()), + ); + } + + public function testCreateIterable() + { + $this->assertEquals(new CollectionType(new BuiltinType(BuiltinTypeEnum::ITERABLE)), Type::iterable()); + + $this->assertEquals( + new CollectionType(new GenericType( + new BuiltinType(BuiltinTypeEnum::ITERABLE), + new UnionType(new BuiltinType(BuiltinTypeEnum::INT), new BuiltinType(BuiltinTypeEnum::STRING)), + new BuiltinType(BuiltinTypeEnum::BOOL), + )), + Type::iterable(Type::bool()), + ); + + $this->assertEquals( + new CollectionType(new GenericType( + new BuiltinType(BuiltinTypeEnum::ITERABLE), + new BuiltinType(BuiltinTypeEnum::STRING), + new BuiltinType(BuiltinTypeEnum::BOOL), + )), + Type::iterable(Type::bool(), Type::string()), + ); + } + + public function testCreateObject() + { + $this->assertEquals(new BuiltinType(BuiltinTypeEnum::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(BuiltinTypeEnum::STRING)), Type::enum(DummyBackedEnum::class)); + $this->assertEquals( + new BackedEnumType(DummyBackedEnum::class, new BuiltinType(BuiltinTypeEnum::INT)), + Type::enum(DummyBackedEnum::class, new BuiltinType(BuiltinTypeEnum::INT)), + ); + } + + public function testCannotCreateUnitEnumWithBackingType() + { + $this->expectException(InvalidArgumentException::class); + Type::enum(DummyEnum::class, new BuiltinType(BuiltinTypeEnum::INT)); + } + + public function testCreateGeneric() + { + $this->assertEquals( + new GenericType(new ObjectType(self::class), new BuiltinType(BuiltinTypeEnum::INT)), + Type::generic(Type::object(self::class), Type::int()), + ); + } + + public function testCreateTemplate() + { + $this->assertEquals(new TemplateType('T'), Type::template('T')); + } + + public function testCreateUnion() + { + $this->assertEquals(new UnionType(new BuiltinType(BuiltinTypeEnum::INT), new ObjectType(self::class)), Type::union(Type::int(), Type::object(self::class))); + $this->assertEquals(new UnionType(new BuiltinType(BuiltinTypeEnum::INT), new BuiltinType(BuiltinTypeEnum::STRING)), Type::union(Type::int(), Type::string(), Type::int())); + $this->assertEquals(new UnionType(new BuiltinType(BuiltinTypeEnum::INT), new BuiltinType(BuiltinTypeEnum::STRING)), Type::union(Type::int(), Type::union(Type::int(), Type::string()))); + } + + public function testCreateIntersection() + { + $this->assertEquals(new IntersectionType(new BuiltinType(BuiltinTypeEnum::INT), new ObjectType(self::class)), Type::intersection(Type::int(), Type::object(self::class))); + $this->assertEquals(new IntersectionType(new BuiltinType(BuiltinTypeEnum::INT), new BuiltinType(BuiltinTypeEnum::STRING)), Type::intersection(Type::int(), Type::string(), Type::int())); + $this->assertEquals(new IntersectionType(new BuiltinType(BuiltinTypeEnum::INT), new BuiltinType(BuiltinTypeEnum::STRING)), Type::intersection(Type::int(), Type::intersection(Type::int(), Type::string()))); + } + + public function testCreateNullable() + { + $this->assertEquals(new UnionType(new BuiltinType(BuiltinTypeEnum::INT), new BuiltinType(BuiltinTypeEnum::NULL)), Type::nullable(Type::int())); + $this->assertEquals(new UnionType(new BuiltinType(BuiltinTypeEnum::INT), new BuiltinType(BuiltinTypeEnum::NULL)), Type::nullable(Type::nullable(Type::int()))); + + $this->assertEquals( + new UnionType(new BuiltinType(BuiltinTypeEnum::INT), new BuiltinType(BuiltinTypeEnum::STRING), new BuiltinType(BuiltinTypeEnum::NULL)), + Type::nullable(Type::union(Type::int(), Type::string())), + ); + $this->assertEquals( + new UnionType(new BuiltinType(BuiltinTypeEnum::INT), new BuiltinType(BuiltinTypeEnum::STRING), new BuiltinType(BuiltinTypeEnum::NULL)), + Type::nullable(Type::union(Type::int(), Type::string(), Type::null())), + ); + } +} diff --git a/src/Symfony/Component/TypeInfo/Tests/TypeResolver/ChainTypeResolverTest.php b/src/Symfony/Component/TypeInfo/Tests/TypeResolver/ChainTypeResolverTest.php new file mode 100644 index 0000000000000..b3a0ec7c6d518 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/TypeResolver/ChainTypeResolverTest.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\Tests\TypeResolver; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\TypeInfo\Exception\UnsupportedException; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeResolver\ChainTypeResolver; +use Symfony\Component\TypeInfo\TypeResolver\TypeResolverInterface; + +class ChainTypeResolverTest extends TestCase +{ + public function testResolve() + { + $firstResolver = $this->createMock(TypeResolverInterface::class); + $firstResolver->method('resolve')->willThrowException(new UnsupportedException('cannot resolve.')); + + $secondResolver = $this->createMock(TypeResolverInterface::class); + $secondResolver->method('resolve')->willReturn(Type::int()); + + $thirdResolver = $this->createMock(TypeResolverInterface::class); + $thirdResolver->method('resolve')->willReturn(Type::string()); + + $resolver = new ChainTypeResolver([$firstResolver, $secondResolver, $thirdResolver]); + + $this->assertEquals(Type::int(), $resolver->resolve('useless')); + } + + public function testCannotResolveIfNoResolverCan() + { + $this->expectException(UnsupportedException::class); + + $resolver = new ChainTypeResolver([]); + $resolver->resolve('int'); + } +} 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..2620b5d3e8e56 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/TypeResolver/ReflectionParameterTypeResolverTest.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\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 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..97ea248b4b029 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/TypeResolver/ReflectionTypeResolverTest.php @@ -0,0 +1,120 @@ + + * + * 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 unhandledTypesDataProvider + */ + public function testCannotResolveInvalidTypes(\ReflectionType $reflection) + { + $this->expectException(UnsupportedException::class); + $this->resolver->resolve($reflection); + } + + /** + * @return iterable + */ + public function unhandledTypesDataProvider(): iterable + { + $reflection = new \ReflectionClass(ReflectionExtractableDummy::class); + + yield [$reflection->getMethod('getVoid')->getReturnType()]; + yield [$reflection->getMethod('getNever')->getReturnType()]; + } + + /** + * @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..17fe49d051f7c --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/TypeResolver/StringTypeResolverTest.php @@ -0,0 +1,180 @@ + + * + * 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\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(); + + // callable + yield [Type::callable(), 'callable(string, int): mixed']; + + // array + yield [Type::list(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::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'), 'T']; + + // 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); + } + + /** + * @dataProvider unhandledTypesDataProvider + */ + public function testCannotResolveInvalidTypes(string $string) + { + $this->expectException(UnsupportedException::class); + $this->resolver->resolve($string); + } + + /** + * @return iterable + */ + public function unhandledTypesDataProvider(): iterable + { + yield ['void']; + yield ['never']; + yield ['never-return']; + yield ['never-returns']; + yield ['no-return']; + } + + public function testCannotResolveThisWithoutTypeContext() + { + $this->expectException(InvalidArgumentException::class); + $this->resolver->resolve('$this'); + } +} diff --git a/src/Symfony/Component/TypeInfo/Tests/TypeTest.php b/src/Symfony/Component/TypeInfo/Tests/TypeTest.php new file mode 100644 index 0000000000000..97bd155cd56cd --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/TypeTest.php @@ -0,0 +1,75 @@ + + * + * 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\BuiltinType; +use Symfony\Component\TypeInfo\Exception\LogicException; +use Symfony\Component\TypeInfo\Type; + +class TypeTest extends TestCase +{ + public function testIs() + { + $isInt = fn (Type $t) => BuiltinType::INT === $t->getBaseType()->getBuiltinType(); + + $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 testIsBuiltinType() + { + $this->assertTrue(Type::int()->isBuiltinType(BuiltinType::INT)); + $this->assertTrue(Type::union(Type::string(), Type::int())->isBuiltinType(BuiltinType::INT)); + $this->assertTrue(Type::generic(Type::int(), Type::string())->isBuiltinType(BuiltinType::INT)); + + $this->assertFalse(Type::string()->isBuiltinType(BuiltinType::INT)); + $this->assertFalse(Type::union(Type::string(), Type::float())->isBuiltinType(BuiltinType::INT)); + $this->assertFalse(Type::generic(Type::string(), Type::int())->isBuiltinType(BuiltinType::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::mixed(), Type::string())->isNullable()); + + $this->assertFalse(Type::int()->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(BuiltinType::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..61807301f9af4 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Type.php @@ -0,0 +1,83 @@ + + * + * 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\BuiltinType as BuiltinTypeEnum; +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 + { + if ($this instanceof UnionType) { + return $this->atLeastOneTypeIs($callable); + } + + if ($this instanceof IntersectionType) { + return $this->everyTypeIs($callable); + } + + return $callable($this); + } + + public function isBuiltinType(BuiltinTypeEnum $builtinType): bool + { + return $this->is(static function (self $t) use ($builtinType): bool { + try { + $b = $t->getBaseType(); + } catch (\LogicException) { + return false; + } + + return $builtinType === $b->getBuiltinType(); + }); + } + + public function isNullable(): bool + { + return $this->isBuiltinType(BuiltinTypeEnum::NULL) || $this->isBuiltinType(BuiltinTypeEnum::MIXED); + } +} diff --git a/src/Symfony/Component/TypeInfo/Type/BackedEnumType.php b/src/Symfony/Component/TypeInfo/Type/BackedEnumType.php new file mode 100644 index 0000000000000..cfbd01ab75610 --- /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\BuiltinType as BuiltinTypeEnum; +use Symfony\Component\TypeInfo\Exception\InvalidArgumentException; + +/** + * @author Mathias Arlaud + * @author Baptiste Leduc + */ +final class BackedEnumType extends EnumType +{ + /** + * @param class-string $className + */ + public function __construct( + string $className, + private readonly BuiltinType $backingType, + ) { + if (!is_subclass_of($className, \BackedEnum::class)) { + throw new InvalidArgumentException(sprintf('"%s" is not a valid backed enum.', $className)); + } + + if (!\in_array($backingType->getBuiltinType(), [BuiltinTypeEnum::INT, BuiltinTypeEnum::STRING], true)) { + throw new InvalidArgumentException(sprintf('"%s" is not a valid enum backing type.', $backingType)); + } + + parent::__construct($className); + } + + 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..dd60b51c052de --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Type/BuiltinType.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\Type; + +use Symfony\Component\TypeInfo\BuiltinType as BuiltinTypeEnum; +use Symfony\Component\TypeInfo\Type; + +/** + * @author Mathias Arlaud + * @author Baptiste Leduc + */ +final class BuiltinType extends Type +{ + public function __construct( + private readonly BuiltinTypeEnum $builtinType, + ) { + } + + public function getBuiltinType(): BuiltinTypeEnum + { + return $this->builtinType; + } + + public function __toString(): string + { + return $this->builtinType->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..fff51548568f9 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Type/CollectionType.php @@ -0,0 +1,76 @@ + + * + * 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; + +/** + * @author Mathias Arlaud + * @author Baptiste Leduc + */ +final class CollectionType extends Type +{ + public function __construct( + private readonly BuiltinType|ObjectType|GenericType $type, + ) { + } + + public function getType(): BuiltinType|ObjectType|GenericType + { + return $this->type; + } + + public function getCollectionKeyType(): Type + { + $defaultCollectionKeyType = self::union(self::int(), self::string()); + + if ($this->type instanceof GenericType) { + return match (\count($this->type->getGenericTypes())) { + 2 => $this->type->getGenericTypes()[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->getGenericTypes())) { + 2 => $this->type->getGenericTypes()[1], + 1 => $this->type->getGenericTypes()[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..9d99436d46ba0 --- /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 (true === $callable($t)) { + return true; + } + } + + return false; + } + + /** + * @param callable(T): bool $callable + */ + public function everyTypeIs(callable $callable): bool + { + foreach ($this->types as $t) { + if (false === $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..41c6149fe9e4e --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Type/EnumType.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\Type; + +use Symfony\Component\TypeInfo\Exception\InvalidArgumentException; + +/** + * @author Mathias Arlaud + * @author Baptiste Leduc + */ +class EnumType extends ObjectType +{ + /** + * @param class-string $className + */ + public function __construct(string $className) + { + if (!is_subclass_of($className, \UnitEnum::class)) { + throw new InvalidArgumentException(sprintf('"%s" is not a valid enum.', $className)); + } + + parent::__construct($className); + } +} diff --git a/src/Symfony/Component/TypeInfo/Type/GenericType.php b/src/Symfony/Component/TypeInfo/Type/GenericType.php new file mode 100644 index 0000000000000..61509bed7b038 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Type/GenericType.php @@ -0,0 +1,70 @@ + + * + * 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; + +/** + * @author Mathias Arlaud + * @author Baptiste Leduc + */ +final class GenericType extends Type +{ + /** + * @var list + */ + private readonly array $genericTypes; + + public function __construct( + private readonly BuiltinType|ObjectType $type, + Type ...$genericTypes, + ) { + $this->genericTypes = $genericTypes; + } + + public function getType(): BuiltinType|ObjectType + { + return $this->type; + } + + /** + * @return list + */ + public function getGenericTypes(): array + { + return $this->genericTypes; + } + + public function __toString(): string + { + $typeString = (string) $this->type; + + $genericTypesString = ''; + $glue = ''; + foreach ($this->genericTypes as $t) { + $genericTypesString .= $glue.((string) $t); + $glue = ','; + } + + return $typeString.'<'.$genericTypesString.'>'; + } + + /** + * 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..2ff6ba3877454 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Type/IntersectionType.php @@ -0,0 +1,41 @@ + + * + * 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; + +/** + * @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; + } +} diff --git a/src/Symfony/Component/TypeInfo/Type/ObjectType.php b/src/Symfony/Component/TypeInfo/Type/ObjectType.php new file mode 100644 index 0000000000000..cd8ce201a0d3d --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Type/ObjectType.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\BuiltinType; +use Symfony\Component\TypeInfo\Type; + +/** + * @author Mathias Arlaud + * @author Baptiste Leduc + */ +class ObjectType extends Type +{ + /** + * @param class-string $className + */ + public function __construct( + private readonly string $className, + ) { + } + + public function getBuiltinType(): BuiltinType + { + return BuiltinType::OBJECT; + } + + /** + * @return class-string + */ + public function getClassName(): string + { + return $this->className; + } + + /** + * @return class-string + */ + 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..72493bbdebef2 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Type/TemplateType.php @@ -0,0 +1,36 @@ + + * + * 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; + +/** + * @author Mathias Arlaud + * @author Baptiste Leduc + */ +final class TemplateType extends Type +{ + public function __construct( + private readonly string $template, + ) { + } + + public function getTemplate(): string + { + return $this->template; + } + + public function __toString(): string + { + return $this->template; + } +} diff --git a/src/Symfony/Component/TypeInfo/Type/UnionType.php b/src/Symfony/Component/TypeInfo/Type/UnionType.php new file mode 100644 index 0000000000000..06570bafc0263 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Type/UnionType.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\BuiltinType as BuiltinTypeEnum; +use Symfony\Component\TypeInfo\Type; + +/** + * @author Mathias Arlaud + * @author Baptiste Leduc + * + * @template T of Type + */ +final class UnionType extends Type +{ + /** + * @use CompositeTypeTrait + */ + use CompositeTypeTrait; + + public function asNonNullable(): Type + { + $types = array_values(array_filter($this->getTypes(), fn (Type $t): bool => !$t->isBuiltinType(BuiltinTypeEnum::NULL))); + + return \count($types) > 1 ? new self(...$types) : $types[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..4e0e98e8769ac --- /dev/null +++ b/src/Symfony/Component/TypeInfo/TypeContext/TypeContext.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\TypeContext; + +use Symfony\Component\TypeInfo\Exception\LogicException; +use Symfony\Component\TypeInfo\Type; + +/** + * @author Mathias Arlaud + * @author Baptiste Leduc + */ +final class TypeContext +{ + /** + * @var array + */ + private static array $classExistCache = []; + + /** + * @param array $uses + * @param list $classTemplates + */ + public function __construct( + public readonly string $calledClassName, + public readonly string $declaringClassName, + public readonly ?string $namespace = null, + public readonly array $uses = [], + public readonly array $classTemplates = [], + ) { + } + + public function resolve(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 resolveDeclaringClass(): string + { + return $this->resolve($this->declaringClassName); + } + + /** + * @return class-string + */ + public function resolveCalledClass(): string + { + return $this->resolve($this->calledClassName); + } + + /** + * @return class-string + */ + public function resolveParentClass(): string + { + $declaringClassName = $this->resolveDeclaringClass(); + + 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->resolve(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..a01c6a3567252 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/TypeContext/TypeContextFactory.php @@ -0,0 +1,172 @@ + + * + * 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 phpDocumentor\Reflection\Types\ContextFactory; +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; + +/** + * @author Mathias Arlaud + * @author Baptiste Leduc + */ +final class TypeContextFactory +{ + /** + * @var array + */ + private static array $reflectionClassCache = []; + + private ?ContextFactory $phpDocumentorContextFactory = null; + 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 + { + if (!class_exists(ContextFactory::class)) { + throw new \LogicException(sprintf('Unable to call "%s()" as the "phpdocumentor/type-resolver" package is not installed. Try running composer require "phpdocumentor/type-resolver".', __METHOD__)); + } + + $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->collectClassTemplates($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), + ); + + return new TypeContext( + $typeContext->calledClassName, + $typeContext->declaringClassName, + $typeContext->namespace, + $typeContext->uses, + $this->collectClassTemplates($declaringClassReflection, $typeContext), + ); + } + + /** + * @return array + */ + private function collectUses(\ReflectionClass $reflection): array + { + $fileName = $reflection->getFileName(); + if (!\is_string($fileName) || !is_file($fileName)) { + return []; + } + + if (false === $contents = @file_get_contents($fileName)) { + throw new RuntimeException(sprintf('Unable to read file "%s".', $fileName)); + } + + $traitUses = []; + foreach ($reflection->getTraits() as $traitReflection) { + $traitUses[] = $this->collectUses($traitReflection); + } + + $uses = array_merge(...$traitUses); + + $this->phpDocumentorContextFactory ??= new ContextFactory(); + $context = $this->phpDocumentorContextFactory->createForNamespace($reflection->getNamespaceName(), $contents); + + return array_merge($context->getNamespaceAliases(), ...$traitUses); + } + + /** + * @return list + */ + private function collectClassTemplates(\ReflectionClass $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 = null; + $typeString = ((string) $tag->value->bound) ?: null; + + try { + if (null !== $typeString) { + $type = $this->stringTypeResolver->resolve($typeString); + } + } catch (UnsupportedException) { + } + + $templates[] = ['name' => $tag->value->name, 'type' => $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..dd00e7d04d590 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/TypeFactoryTrait.php @@ -0,0 +1,221 @@ + + * + * 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\BuiltinType as BuiltinTypeEnum; +use Symfony\Component\TypeInfo\Exception\InvalidArgumentException; +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; + +/** + * @author Mathias Arlaud + * @author Baptiste Leduc + */ +trait TypeFactoryTrait +{ + public static function builtin(BuiltinTypeEnum|string $type): BuiltinType + { + return new BuiltinType(\is_string($type) ? BuiltinTypeEnum::from($type) : $type); + } + + public static function int(): BuiltinType + { + return self::builtin(BuiltinTypeEnum::INT); + } + + public static function float(): BuiltinType + { + return self::builtin(BuiltinTypeEnum::FLOAT); + } + + public static function string(): BuiltinType + { + return self::builtin(BuiltinTypeEnum::STRING); + } + + public static function bool(): BuiltinType + { + return self::builtin(BuiltinTypeEnum::BOOL); + } + + public static function resource(): BuiltinType + { + return self::builtin(BuiltinTypeEnum::RESOURCE); + } + + public static function false(): BuiltinType + { + return self::builtin(BuiltinTypeEnum::FALSE); + } + + public static function true(): BuiltinType + { + return self::builtin(BuiltinTypeEnum::TRUE); + } + + public static function callable(): BuiltinType + { + return self::builtin(BuiltinTypeEnum::CALLABLE); + } + + public static function mixed(): BuiltinType + { + return self::builtin(BuiltinTypeEnum::MIXED); + } + + public static function null(): BuiltinType + { + return self::builtin(BuiltinTypeEnum::NULL); + } + + public static function collection(Type $type, Type $value = null, Type $key = null): CollectionType + { + if (null !== $value || null !== $key) { + $type = self::generic($type, $key ?? self::union(self::int(), self::string()), $value ?? self::mixed()); + } + + return new CollectionType($type); + } + + public static function array(Type $value = null, Type $key = null): CollectionType + { + return self::collection(self::builtin(BuiltinTypeEnum::ARRAY), $value, $key); + } + + public static function iterable(Type $value = null, Type $key = null): CollectionType + { + return self::collection(self::builtin(BuiltinTypeEnum::ITERABLE), $value, $key); + } + + public static function list(Type $value = null): CollectionType + { + return self::array($value, self::int()); + } + + public static function dict(Type $value = null): CollectionType + { + return self::array($value, self::string()); + } + + /** + * @param class-string|null $className + */ + public static function object(string $className = null): BuiltinType|ObjectType + { + return null !== $className ? new ObjectType($className) : new BuiltinType(BuiltinTypeEnum::OBJECT); + } + + /** + * @param class-string $className + */ + public static function enum(string $className, Type $backingType = null): EnumType + { + if (is_subclass_of($className, \BackedEnum::class)) { + if (null === $backingType) { + $reflectionBackingType = (new \ReflectionEnum($className))->getBackingType(); + $builtinType = BuiltinTypeEnum::INT->value === (string) $reflectionBackingType ? BuiltinTypeEnum::INT : BuiltinTypeEnum::STRING; + $backingType = new BuiltinType($builtinType); + } + + $type = new BackedEnumType($className, $backingType); + } else { + if (null !== $backingType) { + throw new InvalidArgumentException(sprintf('Cannot set a backing type for "%s" as it is not a backed enum.', $className)); + } + + $type = new EnumType($className); + } + + return $type; + } + + public static function generic(Type $mainType, Type ...$parametersType): GenericType + { + return new GenericType($mainType, ...$parametersType); + } + + public static function template(string $template): TemplateType + { + return new TemplateType($template); + } + + /** + * @param list $types + * + * @return UnionType + */ + public static function union(Type ...$types): UnionType + { + $unionTypes = []; + + foreach ($types as $type) { + if (!$type instanceof UnionType) { + $unionTypes[] = $type; + + continue; + } + + foreach ($type->getTypes() as $unionType) { + $unionTypes[] = $unionType; + } + } + + return new UnionType(...$unionTypes); + } + + /** + * @param list $types + * + * @return IntersectionType + */ + public static function intersection(Type ...$types): IntersectionType + { + $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 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/TypeResolver/ChainTypeResolver.php b/src/Symfony/Component/TypeInfo/TypeResolver/ChainTypeResolver.php new file mode 100644 index 0000000000000..3f7ff8e67882a --- /dev/null +++ b/src/Symfony/Component/TypeInfo/TypeResolver/ChainTypeResolver.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\TypeResolver; + +use Symfony\Component\TypeInfo\Exception\UnsupportedException; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeContext\TypeContext; + +/** + * @author Mathias Arlaud + * @author Baptiste Leduc + */ +final readonly class ChainTypeResolver implements TypeResolverInterface +{ + /** + * @param iterable $typeResolvers + */ + public function __construct( + private iterable $typeResolvers, + ) { + } + + public function resolve(mixed $subject, TypeContext $typeContext = null): Type + { + foreach ($this->typeResolvers as $typeResolver) { + try { + return $typeResolver->resolve($subject, $typeContext); + } catch (UnsupportedException) { + } + } + + throw new UnsupportedException('Cannot resolve type.'); + } +} diff --git a/src/Symfony/Component/TypeInfo/TypeResolver/ReflectionParameterTypeResolver.php b/src/Symfony/Component/TypeInfo/TypeResolver/ReflectionParameterTypeResolver.php new file mode 100644 index 0000000000000..10f820dddd4d2 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/TypeResolver/ReflectionParameterTypeResolver.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\TypeResolver; + +use Symfony\Component\TypeInfo\Exception\UnsupportedException; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeContext\TypeContext; +use Symfony\Component\TypeInfo\TypeContext\TypeContextFactory; + +/** + * @author Mathias Arlaud + * @author Baptiste Leduc + */ +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))); + } + + $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), 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..7480e297d9c44 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/TypeResolver/ReflectionPropertyTypeResolver.php @@ -0,0 +1,47 @@ + + * + * 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; + +/** + * @author Mathias Arlaud + * @author Baptiste Leduc + */ +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))); + } + + $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), 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..6d05e174bb164 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/TypeResolver/ReflectionReturnTypeResolver.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\TypeResolver; + +use Symfony\Component\TypeInfo\Exception\UnsupportedException; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeContext\TypeContext; +use Symfony\Component\TypeInfo\TypeContext\TypeContextFactory; + +/** + * @author Mathias Arlaud + * @author Baptiste Leduc + */ +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))); + } + + $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), 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..f0825241f1f67 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/TypeResolver/ReflectionTypeResolver.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 Symfony\Component\TypeInfo\BuiltinType; +use Symfony\Component\TypeInfo\Exception\InvalidArgumentException; +use Symfony\Component\TypeInfo\Exception\UnsupportedException; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeContext\TypeContext; + +/** + * @author Mathias Arlaud + * @author Baptiste Leduc + */ +final class ReflectionTypeResolver implements TypeResolverInterface +{ + /** + * @var array + */ + private static array $reflectionEnumCache = []; + + private const UNSUPPORTED_BUILTIN_TYPES = ['void', 'never']; + + 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))); + } + + $builtinTypeOrClass = $subject->getName(); + $nullable = $subject->allowsNull(); + + if (\in_array($builtinTypeOrClass, self::UNSUPPORTED_BUILTIN_TYPES, true)) { + throw new UnsupportedException(sprintf('"%s" type is not supported.', $builtinTypeOrClass)); + } + + if (BuiltinType::ARRAY->value === $builtinTypeOrClass) { + $type = Type::array(); + + return $nullable ? Type::nullable($type) : $type; + } + + if (BuiltinType::ITERABLE->value === $builtinTypeOrClass) { + $type = Type::iterable(); + + return $nullable ? Type::nullable($type) : $type; + } + + if (\in_array($builtinTypeOrClass, [BuiltinType::MIXED->value, BuiltinType::NULL->value], true)) { + return Type::builtin($builtinTypeOrClass); + } + + if ($subject->isBuiltin()) { + $type = Type::builtin(BuiltinType::from($builtinTypeOrClass)); + + return $nullable ? Type::nullable($type) : $type; + } + + if (\in_array(strtolower($builtinTypeOrClass), ['self', 'static', 'parent'], true) && !$typeContext) { + throw new InvalidArgumentException(sprintf('A "%s" must be provided to resolve "%s".', TypeContext::class, strtolower($builtinTypeOrClass))); + } + + /** @var class-string $className */ + $className = match (true) { + 'self' === strtolower($builtinTypeOrClass) => $typeContext->resolveDeclaringClass(), + 'static' === strtolower($builtinTypeOrClass) => $typeContext->resolveCalledClass(), + 'parent' === strtolower($builtinTypeOrClass) => $typeContext->resolveParentClass(), + default => $builtinTypeOrClass, + }; + + 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..c4400c541a93e --- /dev/null +++ b/src/Symfony/Component/TypeInfo/TypeResolver/StringTypeResolver.php @@ -0,0 +1,251 @@ + + * + * 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\BuiltinType; +use Symfony\Component\TypeInfo\Exception\InvalidArgumentException; +use Symfony\Component\TypeInfo\Exception\LogicException; +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; + +/** + * @author Mathias Arlaud + * @author Baptiste Leduc + */ +final class StringTypeResolver implements TypeResolverInterface +{ + /** + * @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 (!class_exists(TypeParser::class)) { + throw new LogicException(sprintf('Unable to call "%s()" as the "phpstan/phpdoc-parser" package is not installed. Try running composer require "phpstan/phpdoc-parser".', __METHOD__)); + } + + if (!\is_string($subject)) { + throw new UnsupportedException(sprintf('Expected subject to be a "string", "%s" given.', get_debug_type($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), previous: $e); + } + } + + private function getTypeFromNode(TypeNode $node, ?TypeContext $typeContext): Type + { + if ($node instanceof CallableTypeNode) { + return Type::callable(); + } + + if ($node instanceof ArrayTypeNode) { + return Type::list($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->resolveCalledClass()); + } + + 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' => Type::object($typeContext->resolveDeclaringClass()), + 'static' => Type::object($typeContext->resolveCalledClass()), + 'parent' => Type::object($typeContext->resolveParentClass()), + 'void', 'never', 'never-return', 'never-returns', 'no-return' => throw new \DomainException(sprintf('Unhandled "%s" identifier.', $node->name)), + default => $this->resolveCustomIdentifier($node->name, $typeContext), + }; + + if ($type instanceof ObjectType && \in_array($type->getClassName(), [\Traversable::class, \Iterator::class, \IteratorAggregate::class], 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->isBuiltinType(BuiltinType::INT)) { + return $type; + } + + $genericTypes = 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($genericTypes)) { + return Type::collection($type, $genericTypes[0], $keyType); + } elseif (2 === \count($genericTypes)) { + return Type::collection($type, $genericTypes[1], $genericTypes[0]); + } + } + + if ($type instanceof ObjectType && \in_array($type->getClassName(), [\Traversable::class, \Iterator::class, \IteratorAggregate::class], true)) { + if (1 === \count($genericTypes)) { + return Type::collection($type, $genericTypes[0], null); + } elseif (2 === \count($genericTypes)) { + return Type::collection($type, $genericTypes[1], $genericTypes[0]); + } + + return Type::collection($type); + } + + return Type::generic($type, ...$genericTypes); + } + + 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 + { + $classNameOrTemplate = $typeContext ? $typeContext->resolve($identifier) : $identifier; + + if (isset(self::$classExistCache[$classNameOrTemplate])) { + return self::$classExistCache[$classNameOrTemplate] ? Type::object($classNameOrTemplate) : Type::template($classNameOrTemplate); + } + + if (class_exists($classNameOrTemplate) || interface_exists($classNameOrTemplate)) { + self::$classExistCache[$classNameOrTemplate] = true; + + return Type::object($classNameOrTemplate); + } + + try { + new \ReflectionClass($classNameOrTemplate); + self::$classExistCache[$classNameOrTemplate] = true; + + return Type::object($classNameOrTemplate); + } catch (\Throwable) { + } + + self::$classExistCache[$classNameOrTemplate] = false; + + return Type::template($classNameOrTemplate); + } +} diff --git a/src/Symfony/Component/TypeInfo/TypeResolver/TypeResolverInterface.php b/src/Symfony/Component/TypeInfo/TypeResolver/TypeResolverInterface.php new file mode 100644 index 0000000000000..5de6f9c3c6144 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/TypeResolver/TypeResolverInterface.php @@ -0,0 +1,31 @@ + + * + * 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; + +/** + * @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..98115111e8efc --- /dev/null +++ b/src/Symfony/Component/TypeInfo/composer.json @@ -0,0 +1,41 @@ +{ + "name": "symfony/type-info", + "type": "library", + "description": "Extracts information about PHP types using metadata of popular sources", + "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" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2", + "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 + + + diff --git a/src/Symfony/Component/VarDumper/Caster/DsPairStub.php b/src/Symfony/Component/VarDumper/Caster/DsPairStub.php index 22112af9c073d..afa2727b11b77 100644 --- a/src/Symfony/Component/VarDumper/Caster/DsPairStub.php +++ b/src/Symfony/Component/VarDumper/Caster/DsPairStub.php @@ -18,7 +18,7 @@ */ class DsPairStub extends Stub { - public function __construct(string|int $key, mixed $value) + public function __construct(mixed $key, mixed $value) { $this->value = [ Caster::PREFIX_VIRTUAL.'key' => $key, diff --git a/src/Symfony/Component/Webhook/Client/RequestParser.php b/src/Symfony/Component/Webhook/Client/RequestParser.php index dc81765abf3a3..3b4b2a922cf86 100644 --- a/src/Symfony/Component/Webhook/Client/RequestParser.php +++ b/src/Symfony/Component/Webhook/Client/RequestParser.php @@ -18,6 +18,7 @@ use Symfony\Component\HttpFoundation\RequestMatcher\MethodRequestMatcher; use Symfony\Component\HttpFoundation\RequestMatcherInterface; use Symfony\Component\RemoteEvent\RemoteEvent; +use Symfony\Component\Webhook\Exception\InvalidArgumentException; use Symfony\Component\Webhook\Exception\RejectWebhookException; /** @@ -43,6 +44,10 @@ protected function getRequestMatcher(): RequestMatcherInterface protected function doParse(Request $request, #[\SensitiveParameter] string $secret): RemoteEvent { + if (!$secret) { + throw new InvalidArgumentException('A non-empty secret is required.'); + } + $body = $request->toArray(); foreach ([$this->signatureHeaderName, $this->eventHeaderName, $this->idHeaderName] as $header) {