diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b4453157..ad0554fb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,6 +22,8 @@ jobs: - "8.0" - "8.1" - "8.2" + - "8.3" + - "8.4" steps: - name: "Checkout" @@ -56,6 +58,7 @@ jobs: with: repository: "phpstan/build-cs" path: "build-cs" + ref: "1.x" - name: "Install PHP" uses: "shivammathur/setup-php@v2" @@ -93,6 +96,8 @@ jobs: - "8.0" - "8.1" - "8.2" + - "8.3" + - "8.4" dependencies: - "lowest" - "highest" @@ -132,6 +137,8 @@ jobs: - "8.0" - "8.1" - "8.2" + - "8.3" + - "8.4" dependencies: - "lowest" - "highest" diff --git a/.github/workflows/lock-closed-issues.yml b/.github/workflows/lock-closed-issues.yml index 4c7990df..047fe906 100644 --- a/.github/workflows/lock-closed-issues.yml +++ b/.github/workflows/lock-closed-issues.yml @@ -2,13 +2,13 @@ name: 'Lock Issues' on: schedule: - - cron: '0 0 * * *' + - cron: '5 0 * * *' jobs: lock: runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v4 + - uses: dessant/lock-threads@v5 with: github-token: ${{ github.token }} issue-inactive-days: '31' diff --git a/.github/workflows/release-toot.yml b/.github/workflows/release-toot.yml index 6a1c8156..1ba4fd77 100644 --- a/.github/workflows/release-toot.yml +++ b/.github/workflows/release-toot.yml @@ -10,7 +10,7 @@ jobs: toot: runs-on: ubuntu-latest steps: - - uses: cbrgm/mastodon-github-action@v1 + - uses: cbrgm/mastodon-github-action@v2 if: ${{ !github.event.repository.private }} with: # GitHub event payload diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e4a8ac62..b1a669a9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: - name: Generate changelog id: changelog - uses: metcalfc/changelog-generator@v4.1.0 + uses: metcalfc/changelog-generator@v4.3.1 with: myToken: ${{ secrets.PHPSTAN_BOT_TOKEN }} diff --git a/Makefile b/Makefile index ecd8cfb2..b01b1537 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ lint: .PHONY: cs-install cs-install: git clone https://github.com/phpstan/build-cs.git || true - git -C build-cs fetch origin && git -C build-cs reset --hard origin/main + git -C build-cs fetch origin && git -C build-cs reset --hard origin/1.x composer install --working-dir build-cs .PHONY: cs diff --git a/README.md b/README.md index 78f02320..25a155c1 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,10 @@ parameters: # If you're using PHP config files for Symfony 5.3+, you also need this for auto-loading of `Symfony\Config`: scanDirectories: - var/cache/dev/Symfony/Config + # If you're using PHP config files (including the ones under packages/*.php) for Symfony 5.3+, + # you need this to load the helper functions (i.e. service(), env()): + scanFiles: + - vendor/symfony/dependency-injection/Loader/Configurator/ContainerConfigurator.php ``` ## Constant hassers diff --git a/composer.json b/composer.json index 2269d9a4..0d5cd41c 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ "require": { "php": "^7.2 || ^8.0", "ext-simplexml": "*", - "phpstan/phpstan": "^1.11" + "phpstan/phpstan": "^1.12" }, "conflict": { "symfony/framework-bundle": "<3.0" diff --git a/extension.neon b/extension.neon index fdfbfdcf..a38fd4bf 100644 --- a/extension.neon +++ b/extension.neon @@ -14,16 +14,19 @@ parameters: featureToggles: skipCheckGenericClasses: - Symfony\Component\Form\AbstractType + - Symfony\Component\Form\FormBuilderInterface + - Symfony\Component\Form\FormConfigBuilderInterface + - Symfony\Component\Form\FormConfigInterface - Symfony\Component\Form\FormInterface - Symfony\Component\Form\FormTypeExtensionInterface - Symfony\Component\Form\FormTypeInterface - Symfony\Component\OptionsResolver\Options - Symfony\Component\Security\Core\Authorization\Voter\Voter - - Symfony\Component\Security\Core\User\PasswordUpgraderInterface stubFiles: - stubs/Psr/Cache/CacheException.stub - stubs/Psr/Cache/CacheItemInterface.stub - stubs/Psr/Cache/InvalidArgumentException.stub + - stubs/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.stub - stubs/Symfony/Bundle/FrameworkBundle/KernelBrowser.stub - stubs/Symfony/Bundle/FrameworkBundle/Test/KernelTestCase.stub - stubs/Symfony/Bundle/FrameworkBundle/Test/TestContainer.stub @@ -47,6 +50,8 @@ parameters: - stubs/Symfony/Component/Form/Exception/TransformationFailedException.stub - stubs/Symfony/Component/Form/DataTransformerInterface.stub - stubs/Symfony/Component/Form/FormBuilderInterface.stub + - stubs/Symfony/Component/Form/FormConfigBuilderInterface.stub + - stubs/Symfony/Component/Form/FormConfigInterface.stub - stubs/Symfony/Component/Form/FormInterface.stub - stubs/Symfony/Component/Form/FormFactoryInterface.stub - stubs/Symfony/Component/Form/FormTypeExtensionInterface.stub @@ -60,7 +65,14 @@ parameters: - stubs/Symfony/Component/Messenger/Envelope.stub - stubs/Symfony/Component/OptionsResolver/Exception/InvalidOptionsException.stub - stubs/Symfony/Component/OptionsResolver/Options.stub + - stubs/Symfony/Component/Process/Exception/LogicException.stub - stubs/Symfony/Component/Process/Process.stub + - stubs/Symfony/Component/PropertyAccess/Exception/AccessException.stub + - stubs/Symfony/Component/PropertyAccess/Exception/ExceptionInterface.stub + - stubs/Symfony/Component/PropertyAccess/Exception/InvalidArgumentException.stub + - stubs/Symfony/Component/PropertyAccess/Exception/RuntimeException.stub + - stubs/Symfony/Component/PropertyAccess/Exception/UnexpectedTypeException.stub + - stubs/Symfony/Component/PropertyAccess/PropertyAccessorInterface.stub - stubs/Symfony/Component/PropertyAccess/PropertyPathInterface.stub - stubs/Symfony/Component/Security/Acl/Model/AclInterface.stub - stubs/Symfony/Component/Security/Acl/Model/EntryInterface.stub @@ -85,11 +97,14 @@ parameters: - stubs/Symfony/Component/Serializer/Normalizer/NormalizableInterface.stub - stubs/Symfony/Component/Serializer/Normalizer/NormalizerInterface.stub - stubs/Symfony/Component/Validator/Constraint.stub + - stubs/Symfony/Component/Validator/Constraints/Composite.stub + - stubs/Symfony/Component/Validator/Constraints/Compound.stub - stubs/Symfony/Component/Validator/ConstraintViolationInterface.stub - stubs/Symfony/Component/Validator/ConstraintViolationListInterface.stub - stubs/Symfony/Contracts/Cache/CacheInterface.stub - stubs/Symfony/Contracts/Cache/CallbackInterface.stub - stubs/Symfony/Contracts/Cache/ItemInterface.stub + - stubs/Symfony/Contracts/Service/ServiceSubscriberInterface.stub - stubs/Twig/Node/Node.stub parametersSchema: @@ -124,6 +139,13 @@ services: - factory: @symfony.parameterMapFactory::create() + # message map + symfony.messageMapFactory: + class: PHPStan\Symfony\MessageMapFactory + factory: PHPStan\Symfony\MessageMapFactory + - + factory: @symfony.messageMapFactory::create() + # ControllerTrait::get()/has() return type - factory: PHPStan\Type\Symfony\ServiceDynamicReturnTypeExtension(Symfony\Component\DependencyInjection\ContainerInterface) @@ -187,6 +209,11 @@ services: factory: PHPStan\Type\Symfony\EnvelopeReturnTypeExtension tags: [phpstan.broker.dynamicMethodReturnTypeExtension] + # Messenger HandleTrait::handle() return type + - + class: PHPStan\Type\Symfony\MessengerHandleTraitReturnTypeExtension + tags: [phpstan.broker.expressionTypeResolverExtension] + # InputInterface::getArgument() return type - factory: PHPStan\Type\Symfony\InputInterfaceGetArgumentDynamicReturnTypeExtension @@ -306,9 +333,9 @@ services: tags: - phpstan.stubFilesExtension - - class: PHPStan\Symfony\PasswordAuthenticatedUserStubFilesExtension + class: PHPStan\Symfony\SymfonyDiagnoseExtension tags: - - phpstan.stubFilesExtension + - phpstan.diagnoseExtension # FormInterface::getErrors() return type - @@ -336,3 +363,13 @@ services: tags: - phpstan.properties.readWriteExtension - phpstan.additionalConstructorsExtension + + # CacheInterface::get() return type + - + factory: PHPStan\Type\Symfony\CacheInterfaceGetDynamicReturnTypeExtension + tags: [phpstan.broker.dynamicMethodReturnTypeExtension] + + # Extension::getConfiguration() return type + - + factory: PHPStan\Type\Symfony\ExtensionGetConfigurationReturnTypeExtension + tags: [phpstan.broker.dynamicMethodReturnTypeExtension] diff --git a/src/Symfony/ConsoleApplicationResolver.php b/src/Symfony/ConsoleApplicationResolver.php index 51bd7960..52f5f4f0 100644 --- a/src/Symfony/ConsoleApplicationResolver.php +++ b/src/Symfony/ConsoleApplicationResolver.php @@ -27,6 +27,11 @@ public function __construct(Configuration $configuration) $this->consoleApplicationLoader = $configuration->getConsoleApplicationLoader(); } + public function hasConsoleApplicationLoader(): bool + { + return $this->consoleApplicationLoader !== null; + } + private function getConsoleApplication(): ?Application { if ($this->consoleApplicationLoader === null) { diff --git a/src/Symfony/MessageMap.php b/src/Symfony/MessageMap.php new file mode 100644 index 00000000..7523742c --- /dev/null +++ b/src/Symfony/MessageMap.php @@ -0,0 +1,24 @@ + */ + private $messageMap; + + /** @param array $messageMap */ + public function __construct(array $messageMap) + { + $this->messageMap = $messageMap; + } + + public function getTypeForClass(string $class): ?Type + { + return $this->messageMap[$class] ?? null; + } + +} diff --git a/src/Symfony/MessageMapFactory.php b/src/Symfony/MessageMapFactory.php new file mode 100644 index 00000000..5c9ef152 --- /dev/null +++ b/src/Symfony/MessageMapFactory.php @@ -0,0 +1,154 @@ +serviceMap = $symfonyServiceMap; + $this->reflectionProvider = $reflectionProvider; + } + + public function create(): MessageMap + { + $returnTypesMap = []; + + foreach ($this->serviceMap->getServices() as $service) { + $serviceClass = $service->getClass(); + + if ($serviceClass === null) { + continue; + } + + foreach ($service->getTags() as $tag) { + if ($tag->getName() !== self::MESSENGER_HANDLER_TAG) { + continue; + } + + if (!$this->reflectionProvider->hasClass($serviceClass)) { + continue; + } + + $reflectionClass = $this->reflectionProvider->getClass($serviceClass); + + /** @var array{handles?: class-string, method?: string} $tagAttributes */ + $tagAttributes = $tag->getAttributes(); + + if (isset($tagAttributes['handles'])) { + $handles = [$tagAttributes['handles'] => ['method' => $tagAttributes['method'] ?? self::DEFAULT_HANDLER_METHOD]]; + } else { + $handles = $this->guessHandledMessages($reflectionClass); + } + + foreach ($handles as $messageClassName => $options) { + $methodName = $options['method'] ?? self::DEFAULT_HANDLER_METHOD; + + if (!$reflectionClass->hasNativeMethod($methodName)) { + continue; + } + + $methodReflection = $reflectionClass->getNativeMethod($methodName); + + foreach ($methodReflection->getVariants() as $variant) { + $returnTypesMap[$messageClassName][] = $variant->getReturnType(); + } + } + } + } + + $messageMap = []; + foreach ($returnTypesMap as $messageClassName => $returnTypes) { + if (count($returnTypes) !== 1) { + continue; + } + + $messageMap[$messageClassName] = $returnTypes[0]; + } + + return new MessageMap($messageMap); + } + + /** @return iterable> */ + private function guessHandledMessages(ClassReflection $reflectionClass): iterable + { + if ($reflectionClass->implementsInterface(MessageSubscriberInterface::class)) { + $className = $reflectionClass->getName(); + + foreach ($className::getHandledMessages() as $index => $value) { + $containOptions = self::containOptions($index, $value); + if ($containOptions === true) { + yield $index => $value; + } elseif ($containOptions === false) { + yield $value => ['method' => self::DEFAULT_HANDLER_METHOD]; + } + } + + return; + } + + if (!$reflectionClass->hasNativeMethod(self::DEFAULT_HANDLER_METHOD)) { + return; + } + + $methodReflection = $reflectionClass->getNativeMethod(self::DEFAULT_HANDLER_METHOD); + + $variants = $methodReflection->getVariants(); + if (count($variants) !== 1) { + return; + } + + $parameters = $variants[0]->getParameters(); + + if (count($parameters) !== 1) { + return; + } + + $classNames = $parameters[0]->getType()->getObjectClassNames(); + + if (count($classNames) !== 1) { + return; + } + + yield $classNames[0] => ['method' => self::DEFAULT_HANDLER_METHOD]; + } + + /** + * @param mixed $index + * @param mixed $value + * @phpstan-assert-if-true =class-string $index + * @phpstan-assert-if-true =array $value + * @phpstan-assert-if-false =int $index + * @phpstan-assert-if-false =class-string $value + */ + private static function containOptions($index, $value): ?bool + { + if (is_string($index) && class_exists($index) && is_array($value)) { + return true; + } elseif (is_int($index) && is_string($value) && class_exists($value)) { + return false; + } + + return null; + } + +} diff --git a/src/Symfony/ParameterDefinition.php b/src/Symfony/ParameterDefinition.php index e1aa2eaa..1da7723b 100644 --- a/src/Symfony/ParameterDefinition.php +++ b/src/Symfony/ParameterDefinition.php @@ -2,6 +2,9 @@ namespace PHPStan\Symfony; +/** + * @api + */ interface ParameterDefinition { diff --git a/src/Symfony/ParameterMap.php b/src/Symfony/ParameterMap.php index ff0f5224..0c551635 100644 --- a/src/Symfony/ParameterMap.php +++ b/src/Symfony/ParameterMap.php @@ -5,6 +5,9 @@ use PhpParser\Node\Expr; use PHPStan\Analyser\Scope; +/** + * @api + */ interface ParameterMap { diff --git a/src/Symfony/PasswordAuthenticatedUserStubFilesExtension.php b/src/Symfony/PasswordAuthenticatedUserStubFilesExtension.php deleted file mode 100644 index 8f8c4782..00000000 --- a/src/Symfony/PasswordAuthenticatedUserStubFilesExtension.php +++ /dev/null @@ -1,36 +0,0 @@ -reflector = $reflector; - } - - public function getFiles(): array - { - try { - $this->reflector->reflectClass('Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface'); - } catch (IdentifierNotFound $e) { - return []; - } - - return [ - __DIR__ . '/../../stubs/Symfony/Component/Security/Core/User/PasswordAuthenticatedUserInterface.stub', - __DIR__ . '/../../stubs/Symfony/Component/Security/Core/User/PasswordUpgraderInterface.stub', - ]; - } - -} diff --git a/src/Symfony/Service.php b/src/Symfony/Service.php index c31324f5..1cc465ac 100644 --- a/src/Symfony/Service.php +++ b/src/Symfony/Service.php @@ -20,12 +20,17 @@ final class Service implements ServiceDefinition /** @var string|null */ private $alias; + /** @var ServiceTag[] */ + private $tags; + + /** @param ServiceTag[] $tags */ public function __construct( string $id, ?string $class, bool $public, bool $synthetic, - ?string $alias + ?string $alias, + array $tags = [] ) { $this->id = $id; @@ -33,6 +38,7 @@ public function __construct( $this->public = $public; $this->synthetic = $synthetic; $this->alias = $alias; + $this->tags = $tags; } public function getId(): string @@ -60,4 +66,9 @@ public function getAlias(): ?string return $this->alias; } + public function getTags(): array + { + return $this->tags; + } + } diff --git a/src/Symfony/ServiceDefinition.php b/src/Symfony/ServiceDefinition.php index c7cdcd18..3862fa8d 100644 --- a/src/Symfony/ServiceDefinition.php +++ b/src/Symfony/ServiceDefinition.php @@ -2,6 +2,9 @@ namespace PHPStan\Symfony; +/** + * @api + */ interface ServiceDefinition { @@ -15,4 +18,7 @@ public function isSynthetic(): bool; public function getAlias(): ?string; + /** @return ServiceTag[] */ + public function getTags(): array; + } diff --git a/src/Symfony/ServiceMap.php b/src/Symfony/ServiceMap.php index 6665ede0..bbd2d8a3 100644 --- a/src/Symfony/ServiceMap.php +++ b/src/Symfony/ServiceMap.php @@ -5,6 +5,9 @@ use PhpParser\Node\Expr; use PHPStan\Analyser\Scope; +/** + * @api + */ interface ServiceMap { diff --git a/src/Symfony/ServiceTag.php b/src/Symfony/ServiceTag.php new file mode 100644 index 00000000..a8437fd1 --- /dev/null +++ b/src/Symfony/ServiceTag.php @@ -0,0 +1,31 @@ + */ + private $attributes; + + /** @param array $attributes */ + public function __construct(string $name, array $attributes = []) + { + $this->name = $name; + $this->attributes = $attributes; + } + + public function getName(): string + { + return $this->name; + } + + public function getAttributes(): array + { + return $this->attributes; + } + +} diff --git a/src/Symfony/ServiceTagDefinition.php b/src/Symfony/ServiceTagDefinition.php new file mode 100644 index 00000000..b0f66d9c --- /dev/null +++ b/src/Symfony/ServiceTagDefinition.php @@ -0,0 +1,13 @@ + */ + public function getAttributes(): array; + +} diff --git a/src/Symfony/SymfonyDiagnoseExtension.php b/src/Symfony/SymfonyDiagnoseExtension.php new file mode 100644 index 00000000..afd566dc --- /dev/null +++ b/src/Symfony/SymfonyDiagnoseExtension.php @@ -0,0 +1,29 @@ +consoleApplicationResolver = $consoleApplicationResolver; + } + + public function print(Output $output): void + { + $output->writeLineFormatted(sprintf( + 'Symfony\'s consoleApplicationLoader: %s', + $this->consoleApplicationResolver->hasConsoleApplicationLoader() ? 'In use' : 'No' + )); + $output->writeLineFormatted(''); + } + +} diff --git a/src/Symfony/XmlServiceMapFactory.php b/src/Symfony/XmlServiceMapFactory.php index ad52c373..0c44207e 100644 --- a/src/Symfony/XmlServiceMapFactory.php +++ b/src/Symfony/XmlServiceMapFactory.php @@ -47,12 +47,22 @@ public function create(): ServiceMap continue; } + $serviceTags = []; + foreach ($def->tag as $tag) { + $tagAttrs = ((array) $tag->attributes())['@attributes'] ?? []; + $tagName = $tagAttrs['name']; + unset($tagAttrs['name']); + + $serviceTags[] = new ServiceTag($tagName, $tagAttrs); + } + $service = new Service( - strpos((string) $attrs->id, '.') === 0 ? substr((string) $attrs->id, 1) : (string) $attrs->id, + $this->cleanServiceId((string) $attrs->id), isset($attrs->class) ? (string) $attrs->class : null, isset($attrs->public) && (string) $attrs->public === 'true', isset($attrs->synthetic) && (string) $attrs->synthetic === 'true', - isset($attrs->alias) ? (string) $attrs->alias : null + isset($attrs->alias) ? $this->cleanServiceId((string) $attrs->alias) : null, + $serviceTags ); if ($service->getAlias() !== null) { @@ -79,4 +89,9 @@ public function create(): ServiceMap return new DefaultServiceMap($services); } + private function cleanServiceId(string $id): string + { + return strpos($id, '.') === 0 ? substr($id, 1) : $id; + } + } diff --git a/src/Type/Symfony/CacheInterfaceGetDynamicReturnTypeExtension.php b/src/Type/Symfony/CacheInterfaceGetDynamicReturnTypeExtension.php new file mode 100644 index 00000000..ce736bfe --- /dev/null +++ b/src/Type/Symfony/CacheInterfaceGetDynamicReturnTypeExtension.php @@ -0,0 +1,48 @@ +getName() === 'get'; + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type + { + if (!isset($methodCall->getArgs()[1])) { + return null; + } + + $callbackReturnType = $scope->getType($methodCall->getArgs()[1]->value); + if ($callbackReturnType->isCallable()->yes()) { + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $scope, + $methodCall->getArgs(), + $callbackReturnType->getCallableParametersAcceptors($scope) + ); + $returnType = $parametersAcceptor->getReturnType(); + + // generalize template parameters + return $returnType->generalize(GeneralizePrecision::templateArgument()); + } + + return null; + } + +} diff --git a/src/Type/Symfony/ExtensionGetConfigurationReturnTypeExtension.php b/src/Type/Symfony/ExtensionGetConfigurationReturnTypeExtension.php new file mode 100644 index 00000000..7af50773 --- /dev/null +++ b/src/Type/Symfony/ExtensionGetConfigurationReturnTypeExtension.php @@ -0,0 +1,105 @@ +reflectionProvider = $reflectionProvider; + } + + public function getClass(): string + { + return 'Symfony\Component\DependencyInjection\Extension\Extension'; + } + + public function isMethodSupported(MethodReflection $methodReflection): bool + { + return $methodReflection->getName() === 'getConfiguration' + && $methodReflection->getDeclaringClass()->getName() === 'Symfony\Component\DependencyInjection\Extension\Extension'; + } + + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope + ): ?Type + { + $types = []; + $extensionType = $scope->getType($methodCall->var); + $classes = $extensionType->getObjectClassNames(); + + foreach ($classes as $extensionName) { + if (str_contains($extensionName, "\0")) { + $types[] = new NullType(); + continue; + } + + $lastBackslash = strrpos($extensionName, '\\'); + if ($lastBackslash === false) { + $types[] = new NullType(); + continue; + } + + $configurationName = substr_replace($extensionName, '\Configuration', $lastBackslash); + if (!$this->reflectionProvider->hasClass($configurationName)) { + $types[] = new NullType(); + continue; + } + + $reflection = $this->reflectionProvider->getClass($configurationName); + if ($this->hasRequiredConstructor($reflection)) { + $types[] = new NullType(); + continue; + } + + $types[] = new ObjectType($configurationName); + } + + return TypeCombinator::union(...$types); + } + + private function hasRequiredConstructor(ClassReflection $class): bool + { + if (!$class->hasConstructor()) { + return false; + } + + $constructor = $class->getConstructor(); + foreach ($constructor->getVariants() as $variant) { + $anyRequired = false; + foreach ($variant->getParameters() as $parameter) { + if (!$parameter->isOptional()) { + $anyRequired = true; + break; + } + } + + if (!$anyRequired) { + return false; + } + } + + return true; + } + +} diff --git a/src/Type/Symfony/InputBagTypeSpecifyingExtension.php b/src/Type/Symfony/InputBagTypeSpecifyingExtension.php index d3ad578c..3a4e54fc 100644 --- a/src/Type/Symfony/InputBagTypeSpecifyingExtension.php +++ b/src/Type/Symfony/InputBagTypeSpecifyingExtension.php @@ -30,7 +30,7 @@ public function getClass(): string public function isMethodSupported(MethodReflection $methodReflection, MethodCall $node, TypeSpecifierContext $context): bool { - return $methodReflection->getName() === self::HAS_METHOD_NAME && !$context->null(); + return $methodReflection->getName() === self::HAS_METHOD_NAME && $context->false(); } public function specifyTypes(MethodReflection $methodReflection, MethodCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes diff --git a/src/Type/Symfony/InputInterfaceGetArgumentDynamicReturnTypeExtension.php b/src/Type/Symfony/InputInterfaceGetArgumentDynamicReturnTypeExtension.php index 0af7c88d..5eb4cfe9 100644 --- a/src/Type/Symfony/InputInterfaceGetArgumentDynamicReturnTypeExtension.php +++ b/src/Type/Symfony/InputInterfaceGetArgumentDynamicReturnTypeExtension.php @@ -10,11 +10,13 @@ use PHPStan\Type\ArrayType; use PHPStan\Type\DynamicMethodReturnTypeExtension; use PHPStan\Type\IntegerType; +use PHPStan\Type\NullType; use PHPStan\Type\StringType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\TypeUtils; use function count; +use function in_array; final class InputInterfaceGetArgumentDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension { @@ -55,6 +57,7 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method $argName = $argStrings[0]->getValue(); $argTypes = []; + $canBeNullInInteract = false; foreach ($this->consoleApplicationResolver->findCommands($classReflection) as $command) { try { $command->mergeApplicationDefinition(); @@ -68,6 +71,8 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method $argType = new StringType(); if (!$argument->isRequired()) { $argType = TypeCombinator::union($argType, $scope->getTypeFromValue($argument->getDefault())); + } else { + $canBeNullInInteract = true; } } $argTypes[] = $argType; @@ -76,7 +81,21 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method } } - return count($argTypes) > 0 ? TypeCombinator::union(...$argTypes) : null; + if (count($argTypes) === 0) { + return null; + } + + $method = $scope->getFunction(); + if ( + $canBeNullInInteract + && $method instanceof MethodReflection + && ($method->getName() === 'interact' || $method->getName() === 'initialize') + && in_array('Symfony\Component\Console\Command\Command', $method->getDeclaringClass()->getParentClassesNames(), true) + ) { + $argTypes[] = new NullType(); + } + + return TypeCombinator::union(...$argTypes); } } diff --git a/src/Type/Symfony/InputInterfaceHasArgumentDynamicReturnTypeExtension.php b/src/Type/Symfony/InputInterfaceHasArgumentDynamicReturnTypeExtension.php index 4ce49c4a..2ca6b4a1 100644 --- a/src/Type/Symfony/InputInterfaceHasArgumentDynamicReturnTypeExtension.php +++ b/src/Type/Symfony/InputInterfaceHasArgumentDynamicReturnTypeExtension.php @@ -13,6 +13,7 @@ use PHPStan\Type\TypeUtils; use function array_unique; use function count; +use function in_array; final class InputInterfaceHasArgumentDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension { @@ -52,6 +53,17 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method } $argName = $argStrings[0]->getValue(); + if ($argName === 'command') { + $method = $scope->getFunction(); + if ( + $method instanceof MethodReflection + && ($method->getName() === 'interact' || $method->getName() === 'initialize') + && in_array('Symfony\Component\Console\Command\Command', $method->getDeclaringClass()->getParentClassesNames(), true) + ) { + return null; + } + } + $returnTypes = []; foreach ($this->consoleApplicationResolver->findCommands($classReflection) as $command) { try { diff --git a/src/Type/Symfony/MessengerHandleTraitReturnTypeExtension.php b/src/Type/Symfony/MessengerHandleTraitReturnTypeExtension.php new file mode 100644 index 00000000..a5dce362 --- /dev/null +++ b/src/Type/Symfony/MessengerHandleTraitReturnTypeExtension.php @@ -0,0 +1,91 @@ +messageMapFactory = $symfonyMessageMapFactory; + } + + public function getType(Expr $expr, Scope $scope): ?Type + { + if ($this->isSupported($expr, $scope)) { + $args = $expr->getArgs(); + if (count($args) !== 1) { + return null; + } + + $arg = $args[0]->value; + $argClassNames = $scope->getType($arg)->getObjectClassNames(); + + if (count($argClassNames) === 1) { + $messageMap = $this->getMessageMap(); + $returnType = $messageMap->getTypeForClass($argClassNames[0]); + + if (!is_null($returnType)) { + return $returnType; + } + } + } + + return null; + } + + private function getMessageMap(): MessageMap + { + if ($this->messageMap === null) { + $this->messageMap = $this->messageMapFactory->create(); + } + + return $this->messageMap; + } + + /** + * @phpstan-assert-if-true =MethodCall $expr + */ + private function isSupported(Expr $expr, Scope $scope): bool + { + if (!($expr instanceof MethodCall) || !($expr->name instanceof Identifier) || $expr->name->name !== self::TRAIT_METHOD_NAME) { + return false; + } + + if (!$scope->isInClass()) { + return false; + } + + $reflectionClass = $scope->getClassReflection()->getNativeReflection(); + + if (!$reflectionClass->hasMethod(self::TRAIT_METHOD_NAME)) { + return false; + } + + $methodReflection = $reflectionClass->getMethod(self::TRAIT_METHOD_NAME); + $declaringClassReflection = $methodReflection->getBetterReflection()->getDeclaringClass(); + + return $declaringClassReflection->getName() === self::TRAIT_NAME; + } + +} diff --git a/src/Type/Symfony/ParameterDynamicReturnTypeExtension.php b/src/Type/Symfony/ParameterDynamicReturnTypeExtension.php index 82d3599e..82535133 100644 --- a/src/Type/Symfony/ParameterDynamicReturnTypeExtension.php +++ b/src/Type/Symfony/ParameterDynamicReturnTypeExtension.php @@ -13,6 +13,7 @@ use PHPStan\Type\ArrayType; use PHPStan\Type\BooleanType; use PHPStan\Type\Constant\ConstantArrayType; +use PHPStan\Type\Constant\ConstantArrayTypeBuilder; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\ConstantType; use PHPStan\Type\DynamicMethodReturnTypeExtension; @@ -168,7 +169,9 @@ private function generalizeTypeFromValue(Scope $scope, $value): Type $valueTypes[] = $this->generalizeTypeFromValue($scope, $element); } - return new ConstantArrayType($keyTypes, $valueTypes); + return ConstantArrayTypeBuilder::createFromConstantArray( + new ConstantArrayType($keyTypes, $valueTypes) + )->getArray(); } return new ArrayType( diff --git a/stubs/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.stub b/stubs/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.stub new file mode 100644 index 00000000..075dce6d --- /dev/null +++ b/stubs/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.stub @@ -0,0 +1,24 @@ + + * @template TData + * + * @param class-string $type + * @param TData $data + * @param array $options + * + * @phpstan-return ($data is null ? FormInterface : FormInterface) + */ + protected function createForm(string $type, $data = null, array $options = []): FormInterface + { + } +} diff --git a/stubs/Symfony/Component/EventDispatcher/EventDispatcherInterface.stub b/stubs/Symfony/Component/EventDispatcher/EventDispatcherInterface.stub index e4ad8fc4..a58e43ca 100644 --- a/stubs/Symfony/Component/EventDispatcher/EventDispatcherInterface.stub +++ b/stubs/Symfony/Component/EventDispatcher/EventDispatcherInterface.stub @@ -10,5 +10,5 @@ interface EventDispatcherInterface * * @return TEvent */ - public function dispatch(object $event, string $eventName = null): object; + public function dispatch(object $event, ?string $eventName = null): object; } diff --git a/stubs/Symfony/Component/EventDispatcher/EventSubscriberInterface.stub b/stubs/Symfony/Component/EventDispatcher/EventSubscriberInterface.stub index 35a77c93..62474d10 100644 --- a/stubs/Symfony/Component/EventDispatcher/EventSubscriberInterface.stub +++ b/stubs/Symfony/Component/EventDispatcher/EventSubscriberInterface.stub @@ -5,7 +5,7 @@ namespace Symfony\Component\EventDispatcher; interface EventSubscriberInterface { /** - * @return array|array|array>> + * @return array> */ public static function getSubscribedEvents(); } diff --git a/stubs/Symfony/Component/Form/AbstractType.stub b/stubs/Symfony/Component/Form/AbstractType.stub index da2b1439..e99b746c 100644 --- a/stubs/Symfony/Component/Form/AbstractType.stub +++ b/stubs/Symfony/Component/Form/AbstractType.stub @@ -11,6 +11,7 @@ abstract class AbstractType implements FormTypeInterface { /** + * @param FormBuilderInterface $builder * @param array $options */ public function buildForm(FormBuilderInterface $builder, array $options): void; diff --git a/stubs/Symfony/Component/Form/FormBuilderInterface.stub b/stubs/Symfony/Component/Form/FormBuilderInterface.stub index 5173bb64..fe578c50 100644 --- a/stubs/Symfony/Component/Form/FormBuilderInterface.stub +++ b/stubs/Symfony/Component/Form/FormBuilderInterface.stub @@ -3,9 +3,17 @@ namespace Symfony\Component\Form; /** - * @extends \Traversable + * @template TData + * + * @extends \Traversable> + * @extends FormConfigBuilderInterface */ -interface FormBuilderInterface extends \Traversable +interface FormBuilderInterface extends \Traversable, \Countable, FormConfigBuilderInterface { + /** + * @return FormInterface + */ + public function getForm(): FormInterface; + } diff --git a/stubs/Symfony/Component/Form/FormConfigBuilderInterface.stub b/stubs/Symfony/Component/Form/FormConfigBuilderInterface.stub new file mode 100644 index 00000000..a167ce43 --- /dev/null +++ b/stubs/Symfony/Component/Form/FormConfigBuilderInterface.stub @@ -0,0 +1,13 @@ + + */ +interface FormConfigBuilderInterface extends FormConfigInterface +{ + +} diff --git a/stubs/Symfony/Component/Form/FormConfigInterface.stub b/stubs/Symfony/Component/Form/FormConfigInterface.stub new file mode 100644 index 00000000..942d467b --- /dev/null +++ b/stubs/Symfony/Component/Form/FormConfigInterface.stub @@ -0,0 +1,16 @@ + $builder * @param array $options */ public function buildForm(FormBuilderInterface $builder, array $options): void; diff --git a/stubs/Symfony/Component/Form/FormTypeInterface.stub b/stubs/Symfony/Component/Form/FormTypeInterface.stub index 2f745283..8536656a 100644 --- a/stubs/Symfony/Component/Form/FormTypeInterface.stub +++ b/stubs/Symfony/Component/Form/FormTypeInterface.stub @@ -8,6 +8,7 @@ namespace Symfony\Component\Form; interface FormTypeInterface { /** + * @param FormBuilderInterface $builder * @param array $options */ public function buildForm(FormBuilderInterface $builder, array $options): void; diff --git a/stubs/Symfony/Component/HttpFoundation/Cookie.stub b/stubs/Symfony/Component/HttpFoundation/Cookie.stub index 40ee45ab..cfb45fa3 100644 --- a/stubs/Symfony/Component/HttpFoundation/Cookie.stub +++ b/stubs/Symfony/Component/HttpFoundation/Cookie.stub @@ -21,7 +21,7 @@ class Cookie * * @throws \InvalidArgumentException */ - public function __construct(string $name, string $value = null, $expire = 0, ?string $path = '/', string $domain = null, ?bool $secure = false, bool $httpOnly = true, bool $raw = false, string $sameSite = null); + public function __construct(string $name, ?string $value = null, $expire = 0, ?string $path = '/', ?string $domain = null, ?bool $secure = false, bool $httpOnly = true, bool $raw = false, ?string $sameSite = null); /** * @param string $name The name of the cookie @@ -36,7 +36,7 @@ class Cookie * * @throws \InvalidArgumentException */ - public function create(string $name, string $value = null, $expire = 0, ?string $path = '/', string $domain = null, ?bool $secure = false, bool $httpOnly = true, bool $raw = false, string $sameSite = null): self; + public function create(string $name, ?string $value = null, $expire = 0, ?string $path = '/', ?string $domain = null, ?bool $secure = false, bool $httpOnly = true, bool $raw = false, ?string $sameSite = null): self; /** * @return self::SAMESITE_*|null diff --git a/stubs/Symfony/Component/Process/Exception/LogicException.stub b/stubs/Symfony/Component/Process/Exception/LogicException.stub new file mode 100644 index 00000000..cb781d6a --- /dev/null +++ b/stubs/Symfony/Component/Process/Exception/LogicException.stub @@ -0,0 +1,8 @@ + */ class Process implements \IteratorAggregate { + /** + * @param int $flags + * + * @return \Generator + * + * @throws LogicException in case the output has been disabled + * @throws LogicException In case the process is not started + */ + public function getIterator(int $flags = 0): \Generator + { + + } + } diff --git a/stubs/Symfony/Component/PropertyAccess/Exception/AccessException.stub b/stubs/Symfony/Component/PropertyAccess/Exception/AccessException.stub new file mode 100644 index 00000000..a763b784 --- /dev/null +++ b/stubs/Symfony/Component/PropertyAccess/Exception/AccessException.stub @@ -0,0 +1,7 @@ + + * @phpstan-param T &$objectOrArray + * @phpstan-param-out ($objectOrArray is object ? T : array) $objectOrArray + * @phpstan-param string|PropertyPathInterface $propertyPath + * @phpstan-param mixed $value + * + * @return void + * + * @throws Exception\InvalidArgumentException If the property path is invalid + * @throws Exception\AccessException If a property/index does not exist or is not public + * @throws Exception\UnexpectedTypeException If a value within the path is neither object nor array + */ + public function setValue(&$objectOrArray, $propertyPath, $value); + +} diff --git a/stubs/Symfony/Component/Security/Core/Authorization/Voter/Voter.stub b/stubs/Symfony/Component/Security/Core/Authorization/Voter/Voter.stub index 17485a28..c2daae3b 100644 --- a/stubs/Symfony/Component/Security/Core/Authorization/Voter/Voter.stub +++ b/stubs/Symfony/Component/Security/Core/Authorization/Voter/Voter.stub @@ -13,6 +13,7 @@ abstract class Voter implements VoterInterface /** * Determines if the attribute and subject are supported by this voter. * + * @param string $attribute * @param mixed $subject * * @phpstan-assert-if-true TSubject $subject diff --git a/stubs/Symfony/Component/Security/Core/User/PasswordAuthenticatedUserInterface.stub b/stubs/Symfony/Component/Security/Core/User/PasswordAuthenticatedUserInterface.stub deleted file mode 100644 index 19cc6040..00000000 --- a/stubs/Symfony/Component/Security/Core/User/PasswordAuthenticatedUserInterface.stub +++ /dev/null @@ -1,7 +0,0 @@ - $options + * @return array + */ + abstract protected function getConstraints(array $options): array; +} diff --git a/stubs/Symfony/Contracts/Cache/CacheInterface.stub b/stubs/Symfony/Contracts/Cache/CacheInterface.stub index ff3027b1..a361ead4 100644 --- a/stubs/Symfony/Contracts/Cache/CacheInterface.stub +++ b/stubs/Symfony/Contracts/Cache/CacheInterface.stub @@ -15,5 +15,5 @@ interface CacheInterface * * @throws InvalidArgumentException */ - public function get(string $key, callable $callback, float $beta = null, array &$metadata = null); + public function get(string $key, callable $callback, ?float $beta = null, ?array &$metadata = null); } diff --git a/stubs/Symfony/Contracts/Service/ServiceSubscriberInterface.stub b/stubs/Symfony/Contracts/Service/ServiceSubscriberInterface.stub new file mode 100644 index 00000000..8860e239 --- /dev/null +++ b/stubs/Symfony/Contracts/Service/ServiceSubscriberInterface.stub @@ -0,0 +1,7 @@ +getAlias()); }, ]; + yield [ + 'aliasForInlined', + static function (?Service $service): void { + self::assertNotNull($service); + self::assertSame('aliasForInlined', $service->getId()); + self::assertNull($service->getClass()); + self::assertFalse($service->isPublic()); + self::assertFalse($service->isSynthetic()); + self::assertSame('inlined', $service->getAlias()); + }, + ]; } } diff --git a/tests/Symfony/container.xml b/tests/Symfony/container.xml index 4be3bd98..f456ab51 100644 --- a/tests/Symfony/container.xml +++ b/tests/Symfony/container.xml @@ -41,5 +41,7 @@ + + diff --git a/tests/Type/Symfony/ExtensionTest.php b/tests/Type/Symfony/ExtensionTest.php index 879abb5d..40420be0 100644 --- a/tests/Type/Symfony/ExtensionTest.php +++ b/tests/Type/Symfony/ExtensionTest.php @@ -14,6 +14,7 @@ class ExtensionTest extends TypeInferenceTestCase /** @return mixed[] */ public function dataFileAsserts(): iterable { + yield from $this->gatherAssertTypes(__DIR__ . '/data/messenger_handle_trait.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/envelope_all.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/header_bag_get.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/response_header_bag_get_cookies.php'); @@ -28,6 +29,7 @@ public function dataFileAsserts(): iterable yield from $this->gatherAssertTypes(__DIR__ . '/data/ExampleOptionCommand.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/ExampleOptionLazyCommand.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/kernel_interface.php'); + yield from $this->gatherAssertTypes(__DIR__ . '/data/property_accessor.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/request_get_content.php'); $ref = new ReflectionMethod(Request::class, 'getSession'); @@ -57,6 +59,15 @@ public function dataFileAsserts(): iterable yield from $this->gatherAssertTypes(__DIR__ . '/data/FormInterface_getErrors.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/cache.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/form_data_type.php'); + + yield from $this->gatherAssertTypes(__DIR__ . '/data/extension/with-configuration/WithConfigurationExtension.php'); + yield from $this->gatherAssertTypes(__DIR__ . '/data/extension/without-configuration/WithoutConfigurationExtension.php'); + yield from $this->gatherAssertTypes(__DIR__ . '/data/extension/anonymous/AnonymousExtension.php'); + yield from $this->gatherAssertTypes(__DIR__ . '/data/extension/ignore-implemented/IgnoreImplementedExtension.php'); + yield from $this->gatherAssertTypes(__DIR__ . '/data/extension/multiple-types/MultipleTypes.php'); + yield from $this->gatherAssertTypes(__DIR__ . '/data/extension/with-configuration-with-constructor/WithConfigurationWithConstructorExtension.php'); + yield from $this->gatherAssertTypes(__DIR__ . '/data/extension/with-configuration-with-constructor-optional-params/WithConfigurationWithConstructorOptionalParamsExtension.php'); + yield from $this->gatherAssertTypes(__DIR__ . '/data/extension/with-configuration-with-constructor-required-params/WithConfigurationWithConstructorRequiredParamsExtension.php'); } /** diff --git a/tests/Type/Symfony/container.xml b/tests/Type/Symfony/container.xml index e1ef6d89..16d4b7fe 100644 --- a/tests/Type/Symfony/container.xml +++ b/tests/Type/Symfony/container.xml @@ -82,6 +82,265 @@ value of b value of c + + v1 + v2 + v3 + v4 + v5 + v6 + v7 + v8 + v9 + v10 + v11 + v12 + v13 + v14 + v15 + v16 + v17 + v18 + v19 + v20 + v21 + v22 + v23 + v24 + v25 + v26 + v27 + v28 + v29 + v30 + v31 + v32 + v33 + v34 + v35 + v36 + v37 + v38 + v39 + v40 + v41 + v42 + v43 + v44 + v45 + v46 + v47 + v48 + v49 + v50 + v51 + v52 + v53 + v54 + v55 + v56 + v57 + v58 + v59 + v60 + v61 + v62 + v63 + v64 + v65 + v66 + v67 + v68 + v69 + v70 + v71 + v72 + v73 + v74 + v75 + v76 + v77 + v78 + v79 + v80 + v81 + v82 + v83 + v84 + v85 + v86 + v87 + v88 + v89 + v90 + v91 + v92 + v93 + v94 + v95 + v96 + v97 + v98 + v99 + v100 + v101 + v102 + v103 + v104 + v105 + v106 + v107 + v108 + v109 + v110 + v111 + v112 + v113 + v114 + v115 + v116 + v117 + v118 + v119 + v120 + v121 + v122 + v123 + v124 + v125 + v126 + v127 + v128 + v129 + v130 + v131 + v132 + v133 + v134 + v135 + v136 + v137 + v138 + v139 + v140 + v141 + v142 + v143 + v144 + v145 + v146 + v147 + v148 + v149 + v150 + v151 + v152 + v153 + v154 + v155 + v156 + v157 + v158 + v159 + v160 + v161 + v162 + v163 + v164 + v165 + v166 + v167 + v168 + v169 + v170 + v171 + v172 + v173 + v174 + v175 + v176 + v177 + v178 + v179 + v180 + v181 + v182 + v183 + v184 + v185 + v186 + v187 + v188 + v189 + v190 + v191 + v192 + v193 + v194 + v195 + v196 + v197 + v198 + v199 + v200 + v201 + v202 + v203 + v204 + v205 + v206 + v207 + v208 + v209 + v210 + v211 + v212 + v213 + v214 + v215 + v216 + v217 + v218 + v219 + v220 + v221 + v222 + v223 + v224 + v225 + v226 + v227 + v228 + v229 + v230 + v231 + v232 + v233 + v234 + v235 + v236 + v237 + v238 + v239 + v240 + v241 + v242 + v243 + v244 + v245 + v246 + v247 + v248 + v249 + v250 + v251 + v252 + v253 + v254 + v255 + v256 + v257 + VGhpcyBpcyBhIEJlbGwgY2hhciAH Y-m-d\TH:i:sP @@ -95,5 +354,23 @@ + + + + + + + + + + + + + + + + + + diff --git a/tests/Type/Symfony/data/ExampleAbstractController.php b/tests/Type/Symfony/data/ExampleAbstractController.php index d9857a53..53b38066 100644 --- a/tests/Type/Symfony/data/ExampleAbstractController.php +++ b/tests/Type/Symfony/data/ExampleAbstractController.php @@ -80,6 +80,7 @@ public function parameters(ContainerInterface $container, ParameterBagInterface assertType("array{a: string, b: string, c: string}", $container->getParameter('app.map')); assertType("array{a: string, b: string, c: string}", $parameterBag->get('app.map')); assertType("array{a: string, b: string, c: string}", $this->getParameter('app.map')); + assertType("non-falsy-string", implode(',', $this->getParameter('app.hugemap'))); assertType("string", $container->getParameter('app.binary')); assertType("string", $parameterBag->get('app.binary')); assertType("string", $this->getParameter('app.binary')); diff --git a/tests/Type/Symfony/data/ExampleBaseCommand.php b/tests/Type/Symfony/data/ExampleBaseCommand.php index 0ab3a322..0376429f 100644 --- a/tests/Type/Symfony/data/ExampleBaseCommand.php +++ b/tests/Type/Symfony/data/ExampleBaseCommand.php @@ -3,6 +3,7 @@ namespace PHPStan\Type\Symfony; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use function PHPStan\Testing\assertType; @@ -14,14 +15,49 @@ protected function configure(): void { parent::configure(); + $this->addArgument('required', InputArgument::REQUIRED); $this->addArgument('base'); } + protected function initialize(InputInterface $input, OutputInterface $output): void + { + assertType('bool', $input->hasArgument('command')); + assertType('string|null', $input->getArgument('command')); + + assertType('string|null', $input->getArgument('base')); + assertType('string', $input->getArgument('aaa')); + assertType('string', $input->getArgument('bbb')); + assertType('string|null', $input->getArgument('required')); + assertType('array|string', $input->getArgument('diff')); + assertType('array', $input->getArgument('arr')); + assertType('string|null', $input->getArgument('both')); + assertType('Symfony\Component\Console\Helper\QuestionHelper', $this->getHelper('question')); + } + + protected function interact(InputInterface $input, OutputInterface $output): void + { + assertType('bool', $input->hasArgument('command')); + assertType('string|null', $input->getArgument('command')); + + assertType('string|null', $input->getArgument('base')); + assertType('string', $input->getArgument('aaa')); + assertType('string', $input->getArgument('bbb')); + assertType('string|null', $input->getArgument('required')); + assertType('array|string', $input->getArgument('diff')); + assertType('array', $input->getArgument('arr')); + assertType('string|null', $input->getArgument('both')); + assertType('Symfony\Component\Console\Helper\QuestionHelper', $this->getHelper('question')); + } + protected function execute(InputInterface $input, OutputInterface $output): int { + assertType('true', $input->hasArgument('command')); + assertType('string', $input->getArgument('command')); + assertType('string|null', $input->getArgument('base')); assertType('string', $input->getArgument('aaa')); assertType('string', $input->getArgument('bbb')); + assertType('string', $input->getArgument('required')); assertType('array|string', $input->getArgument('diff')); assertType('array', $input->getArgument('arr')); assertType('string|null', $input->getArgument('both')); diff --git a/tests/Type/Symfony/data/cache.php b/tests/Type/Symfony/data/cache.php index 6b0728d4..a8862177 100644 --- a/tests/Type/Symfony/data/cache.php +++ b/tests/Type/Symfony/data/cache.php @@ -12,6 +12,26 @@ function testCacheCallable(\Symfony\Contracts\Cache\CacheInterface $cache): voi assertType('string', $result); }; +/** + * @param callable():string $fn + */ +function testNonScalarCacheCallable(\Symfony\Contracts\Cache\CacheInterface $cache, callable $fn): void { + $result = $cache->get('foo', $fn); + + assertType('string', $result); +}; + + +/** + * @param callable():non-empty-string $fn + */ +function testCacheCallableReturnTypeGeneralization(\Symfony\Contracts\Cache\CacheInterface $cache, callable $fn): void { + $result = $cache->get('foo', $fn); + + assertType('string', $result); +}; + + /** * @param \Symfony\Contracts\Cache\CallbackInterface<\stdClass> $cb */ diff --git a/tests/Type/Symfony/data/extension/anonymous/AnonymousExtension.php b/tests/Type/Symfony/data/extension/anonymous/AnonymousExtension.php new file mode 100644 index 00000000..1e33cb37 --- /dev/null +++ b/tests/Type/Symfony/data/extension/anonymous/AnonymousExtension.php @@ -0,0 +1,17 @@ +getConfiguration($configs, $container) + ); + } +}; diff --git a/tests/Type/Symfony/data/extension/ignore-implemented/IgnoreImplementedExtension.php b/tests/Type/Symfony/data/extension/ignore-implemented/IgnoreImplementedExtension.php new file mode 100644 index 00000000..614431f2 --- /dev/null +++ b/tests/Type/Symfony/data/extension/ignore-implemented/IgnoreImplementedExtension.php @@ -0,0 +1,23 @@ +getConfiguration($configs, $container) + ); + } + + public function getConfiguration(array $config, ContainerBuilder $container): ?ConfigurationInterface + { + return null; + } +} diff --git a/tests/Type/Symfony/data/extension/multiple-types/MultipleTypes.php b/tests/Type/Symfony/data/extension/multiple-types/MultipleTypes.php new file mode 100644 index 00000000..77c44003 --- /dev/null +++ b/tests/Type/Symfony/data/extension/multiple-types/MultipleTypes.php @@ -0,0 +1,18 @@ +getConfiguration($configs, $container) + ); +} diff --git a/tests/Type/Symfony/data/extension/with-configuration-with-constructor-optional-params/Configuration.php b/tests/Type/Symfony/data/extension/with-configuration-with-constructor-optional-params/Configuration.php new file mode 100644 index 00000000..4ff16c39 --- /dev/null +++ b/tests/Type/Symfony/data/extension/with-configuration-with-constructor-optional-params/Configuration.php @@ -0,0 +1,16 @@ +getConfiguration($configs, $container) + ); + } +} diff --git a/tests/Type/Symfony/data/extension/with-configuration-with-constructor-required-params/Configuration.php b/tests/Type/Symfony/data/extension/with-configuration-with-constructor-required-params/Configuration.php new file mode 100644 index 00000000..b9d5bcc1 --- /dev/null +++ b/tests/Type/Symfony/data/extension/with-configuration-with-constructor-required-params/Configuration.php @@ -0,0 +1,16 @@ +getConfiguration($configs, $container) + ); + } +} diff --git a/tests/Type/Symfony/data/extension/with-configuration-with-constructor/Configuration.php b/tests/Type/Symfony/data/extension/with-configuration-with-constructor/Configuration.php new file mode 100644 index 00000000..8eea9eb9 --- /dev/null +++ b/tests/Type/Symfony/data/extension/with-configuration-with-constructor/Configuration.php @@ -0,0 +1,16 @@ +getConfiguration($configs, $container) + ); + } +} diff --git a/tests/Type/Symfony/data/extension/with-configuration/Configuration.php b/tests/Type/Symfony/data/extension/with-configuration/Configuration.php new file mode 100644 index 00000000..4e8c51b5 --- /dev/null +++ b/tests/Type/Symfony/data/extension/with-configuration/Configuration.php @@ -0,0 +1,12 @@ +getConfiguration($configs, $container) + ); + } +} diff --git a/tests/Type/Symfony/data/extension/without-configuration/WithoutConfigurationExtension.php b/tests/Type/Symfony/data/extension/without-configuration/WithoutConfigurationExtension.php new file mode 100644 index 00000000..dccec3e2 --- /dev/null +++ b/tests/Type/Symfony/data/extension/without-configuration/WithoutConfigurationExtension.php @@ -0,0 +1,17 @@ +getConfiguration($configs, $container) + ); + } +} diff --git a/tests/Type/Symfony/data/form_data_type.php b/tests/Type/Symfony/data/form_data_type.php index 524a5b7c..34a673a4 100644 --- a/tests/Type/Symfony/data/form_data_type.php +++ b/tests/Type/Symfony/data/form_data_type.php @@ -2,6 +2,7 @@ namespace GenericFormDataType; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\NumberType; use Symfony\Component\Form\Extension\Core\Type\TextType; @@ -29,6 +30,9 @@ class DataClassType extends AbstractType public function buildForm(FormBuilderInterface $builder, array $options): void { + assertType('GenericFormDataType\DataClass|null', $builder->getData()); + assertType('GenericFormDataType\DataClass|null', $builder->getForm()->getData()); + $builder ->add('foo', NumberType::class) ->add('bar', TextType::class) @@ -70,3 +74,20 @@ public function doSomethingNullable(): void } } + +class FormController extends AbstractController +{ + + public function doSomething(): void + { + $form = $this->createForm(DataClassType::class, new DataClass()); + assertType('GenericFormDataType\DataClass', $form->getData()); + } + + public function doSomethingNullable(): void + { + $form = $this->createForm(DataClassType::class); + assertType('GenericFormDataType\DataClass|null', $form->getData()); + } + +} diff --git a/tests/Type/Symfony/data/input_bag.php b/tests/Type/Symfony/data/input_bag.php index efd08461..744a7765 100644 --- a/tests/Type/Symfony/data/input_bag.php +++ b/tests/Type/Symfony/data/input_bag.php @@ -7,7 +7,8 @@ assertType('bool|float|int|string|null', $bag->get('foo')); if ($bag->has('foo')) { - assertType('bool|float|int|string', $bag->get('foo')); + // Because `has` rely on `array_key_exists` we can still have set the NULL value. + assertType('bool|float|int|string|null', $bag->get('foo')); assertType('bool|float|int|string|null', $bag->get('bar')); } else { assertType('null', $bag->get('foo')); diff --git a/tests/Type/Symfony/data/messenger_handle_trait.php b/tests/Type/Symfony/data/messenger_handle_trait.php new file mode 100644 index 00000000..7a86d482 --- /dev/null +++ b/tests/Type/Symfony/data/messenger_handle_trait.php @@ -0,0 +1,113 @@ + ['method' => 'handleInt']; + yield FloatQuery::class => ['method' => 'handleFloat']; + yield StringQuery::class => ['method' => 'handleString']; + } + + public function __invoke(BooleanQuery $query): bool + { + return true; + } + + public function handleInt(IntQuery $query): int + { + return 0; + } + + public function handleFloat(FloatQuery $query): float + { + return 0.0; + } + + public function handleString(StringQuery $query): string + { + return 'string result'; + } +} + +class TaggedQuery {} +class TaggedResult {} +class TaggedHandler +{ + public function handle(TaggedQuery $query): TaggedResult + { + return new TaggedResult(); + } +} + +class MultiHandlesForInTheSameHandlerQuery {} +class MultiHandlesForInTheSameHandler implements MessageSubscriberInterface +{ + public static function getHandledMessages(): iterable + { + yield MultiHandlesForInTheSameHandlerQuery::class; + yield MultiHandlesForInTheSameHandlerQuery::class => ['priority' => '0']; + } + + public function __invoke(MultiHandlesForInTheSameHandlerQuery $query): bool + { + return true; + } +} + +class MultiHandlersForTheSameMessageQuery {} +class MultiHandlersForTheSameMessageHandler1 +{ + public function __invoke(MultiHandlersForTheSameMessageQuery $query): bool + { + return true; + } +} +class MultiHandlersForTheSameMessageHandler2 +{ + public function __invoke(MultiHandlersForTheSameMessageQuery $query): bool + { + return false; + } +} + +class HandleTraitClass { + use HandleTrait; + + public function __invoke() + { + assertType(RegularQueryResult::class, $this->handle(new RegularQuery())); + + assertType('bool', $this->handle(new BooleanQuery())); + assertType('int', $this->handle(new IntQuery())); + assertType('float', $this->handle(new FloatQuery())); + assertType('string', $this->handle(new StringQuery())); + + assertType(TaggedResult::class, $this->handle(new TaggedQuery())); + + // HandleTrait will throw exception in fact due to multiple handle methods/handlers per single query + assertType('mixed', $this->handle(new MultiHandlesForInTheSameHandlerQuery())); + assertType('mixed', $this->handle(new MultiHandlersForTheSameMessageQuery())); + } +} diff --git a/tests/Type/Symfony/data/property_accessor.php b/tests/Type/Symfony/data/property_accessor.php new file mode 100644 index 00000000..0e445684 --- /dev/null +++ b/tests/Type/Symfony/data/property_accessor.php @@ -0,0 +1,13 @@ + 'ea']; +$propertyAccessor->setValue($array, 'foo', 'bar'); +assertType('array', $array); + +$object = new \stdClass(); +$propertyAccessor->setValue($object, 'foo', 'bar'); +assertType('stdClass', $object);