diff --git a/README.md b/README.md index 9d09641b..1017227d 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,8 @@ This extension provides following features: -* Provides correct return type for `ContainerInterface::get()` method. -* Provides correct return type for `Controller::get()` method. +* Provides correct return type for `ContainerInterface::get()` and `::has()` methods. +* Provides correct return type for `Controller::get()` and `::has()` methods. * Provides correct return type for `Request::getContent()` method based on the `$asResource` parameter. * Notifies you when you try to get an unregistered service from the container. * Notifies you when you try to get a private service from the container. diff --git a/extension.neon b/extension.neon index a6fdba36..0d17016f 100644 --- a/extension.neon +++ b/extension.neon @@ -19,4 +19,15 @@ services: class: PHPStan\Rules\Symfony\ContainerInterfaceUnknownServiceRule tags: - phpstan.rules.rule - - PHPStan\Symfony\ServiceMap(%symfony.container_xml_path%) + - + class: PHPStan\Type\Symfony\ContainerInterfaceMethodTypeSpecifyingExtension + tags: + - phpstan.typeSpecifier.methodTypeSpecifyingExtension + - + class: PHPStan\Type\Symfony\ControllerMethodTypeSpecifyingExtension + tags: + - phpstan.typeSpecifier.methodTypeSpecifyingExtension + - + class: PHPStan\Symfony\ServiceMap + arguments: + containerXml: %symfony.container_xml_path% diff --git a/src/Rules/Symfony/ContainerInterfaceUnknownServiceRule.php b/src/Rules/Symfony/ContainerInterfaceUnknownServiceRule.php index 5a0f1c40..12029cb9 100644 --- a/src/Rules/Symfony/ContainerInterfaceUnknownServiceRule.php +++ b/src/Rules/Symfony/ContainerInterfaceUnknownServiceRule.php @@ -4,10 +4,12 @@ use PhpParser\Node; use PhpParser\Node\Expr\MethodCall; +use PhpParser\PrettyPrinter\Standard; use PHPStan\Analyser\Scope; use PHPStan\Rules\Rule; use PHPStan\Symfony\ServiceMap; use PHPStan\Type\ObjectType; +use PHPStan\Type\Symfony\Helper; final class ContainerInterfaceUnknownServiceRule implements Rule { @@ -15,9 +17,13 @@ final class ContainerInterfaceUnknownServiceRule implements Rule /** @var ServiceMap */ private $serviceMap; - public function __construct(ServiceMap $symfonyServiceMap) + /** @var \PhpParser\PrettyPrinter\Standard */ + private $printer; + + public function __construct(ServiceMap $symfonyServiceMap, Standard $printer) { $this->serviceMap = $symfonyServiceMap; + $this->printer = $printer; } public function getNodeType(): string @@ -50,7 +56,7 @@ public function processNode(Node $node, Scope $scope): array $serviceId = ServiceMap::getServiceIdFromNode($node->args[0]->value, $scope); if ($serviceId !== null) { $service = $this->serviceMap->getService($serviceId); - if ($service === null) { + if ($service === null && !$scope->isSpecified(Helper::createMarkerNode($node->var, $scope->getType($node->args[0]->value), $this->printer))) { return [sprintf('Service "%s" is not registered in the container.', $serviceId)]; } } diff --git a/src/Type/Symfony/ContainerInterfaceDynamicReturnTypeExtension.php b/src/Type/Symfony/ContainerInterfaceDynamicReturnTypeExtension.php index 2b2433da..e01682aa 100644 --- a/src/Type/Symfony/ContainerInterfaceDynamicReturnTypeExtension.php +++ b/src/Type/Symfony/ContainerInterfaceDynamicReturnTypeExtension.php @@ -5,10 +5,8 @@ use PhpParser\Node\Expr\MethodCall; use PHPStan\Analyser\Scope; use PHPStan\Reflection\MethodReflection; -use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Symfony\ServiceMap; use PHPStan\Type\DynamicMethodReturnTypeExtension; -use PHPStan\Type\ObjectType; use PHPStan\Type\Type; final class ContainerInterfaceDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension @@ -29,7 +27,7 @@ public function getClass(): string public function isMethodSupported(MethodReflection $methodReflection): bool { - return $methodReflection->getName() === 'get'; + return in_array($methodReflection->getName(), ['get', 'has'], true); } public function getTypeFromMethodCall( @@ -38,20 +36,13 @@ public function getTypeFromMethodCall( Scope $scope ): Type { - $returnType = ParametersAcceptorSelector::selectSingle($methodReflection->getVariants())->getReturnType(); - if (!isset($methodCall->args[0])) { - return $returnType; + switch ($methodReflection->getName()) { + case 'get': + return Helper::getGetTypeFromMethodCall($methodReflection, $methodCall, $scope, $this->serviceMap); + case 'has': + return Helper::getHasTypeFromMethodCall($methodReflection, $methodCall, $scope, $this->serviceMap); } - - $serviceId = ServiceMap::getServiceIdFromNode($methodCall->args[0]->value, $scope); - if ($serviceId !== null) { - $service = $this->serviceMap->getService($serviceId); - if ($service !== null && !$service->isSynthetic()) { - return new ObjectType($service->getClass() ?? $serviceId); - } - } - - return $returnType; + throw new \PHPStan\ShouldNotHappenException(); } } diff --git a/src/Type/Symfony/ContainerInterfaceMethodTypeSpecifyingExtension.php b/src/Type/Symfony/ContainerInterfaceMethodTypeSpecifyingExtension.php new file mode 100644 index 00000000..e4bf5a48 --- /dev/null +++ b/src/Type/Symfony/ContainerInterfaceMethodTypeSpecifyingExtension.php @@ -0,0 +1,49 @@ +printer = $printer; + } + + public function getClass(): string + { + return 'Symfony\Component\DependencyInjection\ContainerInterface'; + } + + public function isMethodSupported(MethodReflection $methodReflection, MethodCall $node, TypeSpecifierContext $context): bool + { + return $methodReflection->getName() === 'has' && !$context->null(); + } + + public function specifyTypes(MethodReflection $methodReflection, MethodCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + return Helper::specifyTypes($methodReflection, $node, $scope, $context, $this->typeSpecifier, $this->printer); + } + + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void + { + $this->typeSpecifier = $typeSpecifier; + } + +} diff --git a/src/Type/Symfony/ControllerDynamicReturnTypeExtension.php b/src/Type/Symfony/ControllerDynamicReturnTypeExtension.php index 79d6ef31..36536218 100644 --- a/src/Type/Symfony/ControllerDynamicReturnTypeExtension.php +++ b/src/Type/Symfony/ControllerDynamicReturnTypeExtension.php @@ -5,10 +5,8 @@ use PhpParser\Node\Expr\MethodCall; use PHPStan\Analyser\Scope; use PHPStan\Reflection\MethodReflection; -use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Symfony\ServiceMap; use PHPStan\Type\DynamicMethodReturnTypeExtension; -use PHPStan\Type\ObjectType; use PHPStan\Type\Type; final class ControllerDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension @@ -29,7 +27,7 @@ public function getClass(): string public function isMethodSupported(MethodReflection $methodReflection): bool { - return $methodReflection->getName() === 'get'; + return in_array($methodReflection->getName(), ['get', 'has'], true); } public function getTypeFromMethodCall( @@ -38,20 +36,13 @@ public function getTypeFromMethodCall( Scope $scope ): Type { - $returnType = ParametersAcceptorSelector::selectSingle($methodReflection->getVariants())->getReturnType(); - if (!isset($methodCall->args[0])) { - return $returnType; + switch ($methodReflection->getName()) { + case 'get': + return Helper::getGetTypeFromMethodCall($methodReflection, $methodCall, $scope, $this->serviceMap); + case 'has': + return Helper::getHasTypeFromMethodCall($methodReflection, $methodCall, $scope, $this->serviceMap); } - - $serviceId = ServiceMap::getServiceIdFromNode($methodCall->args[0]->value, $scope); - if ($serviceId !== null) { - $service = $this->serviceMap->getService($serviceId); - if ($service !== null && !$service->isSynthetic()) { - return new ObjectType($service->getClass() ?? $serviceId); - } - } - - return $returnType; + throw new \PHPStan\ShouldNotHappenException(); } } diff --git a/src/Type/Symfony/ControllerMethodTypeSpecifyingExtension.php b/src/Type/Symfony/ControllerMethodTypeSpecifyingExtension.php new file mode 100644 index 00000000..a1a3bcb4 --- /dev/null +++ b/src/Type/Symfony/ControllerMethodTypeSpecifyingExtension.php @@ -0,0 +1,49 @@ +printer = $printer; + } + + public function getClass(): string + { + return 'Symfony\Bundle\FrameworkBundle\Controller\Controller'; + } + + public function isMethodSupported(MethodReflection $methodReflection, MethodCall $node, TypeSpecifierContext $context): bool + { + return $methodReflection->getName() === 'has' && !$context->null(); + } + + public function specifyTypes(MethodReflection $methodReflection, MethodCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + return Helper::specifyTypes($methodReflection, $node, $scope, $context, $this->typeSpecifier, $this->printer); + } + + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void + { + $this->typeSpecifier = $typeSpecifier; + } + +} diff --git a/src/Type/Symfony/Helper.php b/src/Type/Symfony/Helper.php new file mode 100644 index 00000000..a3cbd2e8 --- /dev/null +++ b/src/Type/Symfony/Helper.php @@ -0,0 +1,96 @@ +getVariants())->getReturnType(); + if (!isset($methodCall->args[0])) { + return $returnType; + } + + $serviceId = ServiceMap::getServiceIdFromNode($methodCall->args[0]->value, $scope); + if ($serviceId !== null) { + $service = $serviceMap->getService($serviceId); + if ($service !== null && !$service->isSynthetic()) { + return new ObjectType($service->getClass() ?? $serviceId); + } + } + + return $returnType; + } + + public static function getHasTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ServiceMap $serviceMap + ): Type + { + $returnType = ParametersAcceptorSelector::selectSingle($methodReflection->getVariants())->getReturnType(); + if (!isset($methodCall->args[0])) { + return $returnType; + } + + $serviceId = ServiceMap::getServiceIdFromNode($methodCall->args[0]->value, $scope); + if ($serviceId !== null) { + $service = $serviceMap->getService($serviceId); + return new ConstantBooleanType($service !== null && $service->isPublic()); + } + + return $returnType; + } + + public static function specifyTypes( + MethodReflection $methodReflection, + MethodCall $node, + Scope $scope, + TypeSpecifierContext $context, + TypeSpecifier $typeSpecifier, + Standard $printer + ): SpecifiedTypes + { + if (!isset($node->args[0])) { + return new SpecifiedTypes(); + } + $argType = $scope->getType($node->args[0]->value); + return $typeSpecifier->create( + self::createMarkerNode($node->var, $argType, $printer), + $argType, + $context + ); + } + + public static function createMarkerNode(Expr $expr, Type $type, Standard $printer): Expr + { + return new Expr\Variable(md5(sprintf( + '%s::%s', + $printer->prettyPrintExpr($expr), + $type->describe(VerbosityLevel::value()) + ))); + } + +} diff --git a/tests/Rules/Symfony/ContainerInterfaceUnknownServiceRuleTest.php b/tests/Rules/Symfony/ContainerInterfaceUnknownServiceRuleTest.php index 86db511b..1ff7d217 100644 --- a/tests/Rules/Symfony/ContainerInterfaceUnknownServiceRuleTest.php +++ b/tests/Rules/Symfony/ContainerInterfaceUnknownServiceRuleTest.php @@ -2,8 +2,11 @@ namespace PHPStan\Rules\Symfony; +use PhpParser\PrettyPrinter\Standard; use PHPStan\Rules\Rule; use PHPStan\Symfony\ServiceMap; +use PHPStan\Type\Symfony\ContainerInterfaceMethodTypeSpecifyingExtension; +use PHPStan\Type\Symfony\ControllerMethodTypeSpecifyingExtension; final class ContainerInterfaceUnknownServiceRuleTest extends \PHPStan\Testing\RuleTestCase { @@ -12,7 +15,18 @@ protected function getRule(): Rule { $serviceMap = new ServiceMap(__DIR__ . '/../../Symfony/data/container.xml'); - return new ContainerInterfaceUnknownServiceRule($serviceMap); + return new ContainerInterfaceUnknownServiceRule($serviceMap, new Standard()); + } + + /** + * @return \PHPStan\Type\MethodTypeSpecifyingExtension[] + */ + protected function getMethodTypeSpecifyingExtensions(): array + { + return [ + new ContainerInterfaceMethodTypeSpecifyingExtension(new Standard()), + new ControllerMethodTypeSpecifyingExtension(new Standard()), + ]; } public function testGetUnknownService(): void diff --git a/tests/Symfony/data/ExampleController.php b/tests/Symfony/data/ExampleController.php index 379f5f55..537c7cbd 100644 --- a/tests/Symfony/data/ExampleController.php +++ b/tests/Symfony/data/ExampleController.php @@ -56,4 +56,12 @@ private function getTestContainer(): TestContainer { } + public function testGetUnknownServiceWithGuard(): void + { + if ($this->has('service.not.found')) { + $service = $this->get('service.not.found'); + $service->noMethod(); + } + } + } diff --git a/tests/Type/Symfony/ContainerInterfaceDynamicReturnTypeExtensionTest.php b/tests/Type/Symfony/ContainerInterfaceDynamicReturnTypeExtensionTest.php index b3119817..3d4a49a8 100644 --- a/tests/Type/Symfony/ContainerInterfaceDynamicReturnTypeExtensionTest.php +++ b/tests/Type/Symfony/ContainerInterfaceDynamicReturnTypeExtensionTest.php @@ -74,6 +74,7 @@ public function getTypeFromMethodCallProvider(): array $parametersAcceptorFound = $this->createMock(ParametersAcceptor::class); $parametersAcceptorFound->expects(self::once())->method('getReturnType')->willReturn($foundType); $methodReflectionFound = $this->createMock(MethodReflection::class); + $methodReflectionFound->expects(self::once())->method('getName')->willReturn('get'); $methodReflectionFound->expects(self::once())->method('getVariants')->willReturn([$parametersAcceptorFound]); $scopeFound = $this->createMock(Scope::class); $scopeFound->expects(self::once())->method('getType')->willReturn(new ConstantStringType('withClass')); @@ -82,6 +83,7 @@ public function getTypeFromMethodCallProvider(): array $parametersAcceptorNotFound = $this->createMock(ParametersAcceptor::class); $parametersAcceptorNotFound->expects(self::once())->method('getReturnType')->willReturn($notFoundType); $methodReflectionNotFound = $this->createMock(MethodReflection::class); + $methodReflectionNotFound->expects(self::once())->method('getName')->willReturn('get'); $methodReflectionNotFound->expects(self::once())->method('getVariants')->willReturn([$parametersAcceptorNotFound]); $scopeNotFound = $this->createMock(Scope::class); $scopeNotFound->expects(self::once())->method('getType')->willReturn(new ErrorType()); diff --git a/tests/Type/Symfony/ControllerDynamicReturnTypeExtensionTest.php b/tests/Type/Symfony/ControllerDynamicReturnTypeExtensionTest.php index 14b643d2..2938262e 100644 --- a/tests/Type/Symfony/ControllerDynamicReturnTypeExtensionTest.php +++ b/tests/Type/Symfony/ControllerDynamicReturnTypeExtensionTest.php @@ -74,6 +74,7 @@ public function getTypeFromMethodCallProvider(): array $parametersAcceptorFound = $this->createMock(ParametersAcceptor::class); $parametersAcceptorFound->expects(self::once())->method('getReturnType')->willReturn($foundType); $methodReflectionFound = $this->createMock(MethodReflection::class); + $methodReflectionFound->expects(self::once())->method('getName')->willReturn('get'); $methodReflectionFound->expects(self::once())->method('getVariants')->willReturn([$parametersAcceptorFound]); $scopeFound = $this->createMock(Scope::class); $scopeFound->expects(self::once())->method('getType')->willReturn(new ConstantStringType('withClass')); @@ -82,6 +83,7 @@ public function getTypeFromMethodCallProvider(): array $parametersAcceptorNotFound = $this->createMock(ParametersAcceptor::class); $parametersAcceptorNotFound->expects(self::once())->method('getReturnType')->willReturn($notFoundType); $methodReflectionNotFound = $this->createMock(MethodReflection::class); + $methodReflectionNotFound->expects(self::once())->method('getName')->willReturn('get'); $methodReflectionNotFound->expects(self::once())->method('getVariants')->willReturn([$parametersAcceptorNotFound]); $scopeNotFound = $this->createMock(Scope::class); $scopeNotFound->expects(self::once())->method('getType')->willReturn(new ErrorType());