diff --git a/README.md b/README.md index 87f25054..492c8795 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,11 @@ This extension provides following features: * 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. +* Provides correct return type for `HeaderBag::get()` method based on the `$first` parameter. +* Provides correct return type for `Envelope::all()` method based on the `$stampFqcn` 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. +* Optionally correct return types for `InputInterface::getArgument()`, `::getOption`, `::hasArgument`, and `::hasOption`. ## Usage @@ -55,3 +58,21 @@ parameters: ``` Be aware that it may hide genuine errors in your application. + +## Console command analysis + +You can opt in for more advanced analysis by providing the console application from your own application. This will allow the correct argument and option types to be inferred when accessing $input->getArgument() or $input->getOption(). + +``` +parameters: + symfony: + console_application_loader: tests/console-application.php +``` + +For example, in a Symfony project, `console-application.php` would look something like this: + +```php +require dirname(__DIR__).'/../config/bootstrap.php'; +$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']); +return new \Symfony\Bundle\FrameworkBundle\Console\Application($kernel); +``` diff --git a/composer.json b/composer.json index 4118192a..16e4658d 100644 --- a/composer.json +++ b/composer.json @@ -33,8 +33,9 @@ "phpstan/phpstan-phpunit": "^0.11", "symfony/framework-bundle": "^3.0 || ^4.0", "squizlabs/php_codesniffer": "^3.3.2", - "symfony/serializer": "^3|^4", - "symfony/messenger": "^4.2" + "symfony/serializer": "^3.0 || ^4.0", + "symfony/messenger": "^4.2", + "symfony/console": "^3.0 || ^4.0" }, "conflict": { "symfony/framework-bundle": "<3.0" diff --git a/extension.neon b/extension.neon index ca7288f2..a98009f5 100644 --- a/extension.neon +++ b/extension.neon @@ -1,12 +1,23 @@ parameters: symfony: + container_xml_path: null constant_hassers: true + console_application_loader: null rules: - PHPStan\Rules\Symfony\ContainerInterfacePrivateServiceRule - PHPStan\Rules\Symfony\ContainerInterfaceUnknownServiceRule + - PHPStan\Rules\Symfony\UndefinedArgumentRule + - PHPStan\Rules\Symfony\InvalidArgumentDefaultValueRule + - PHPStan\Rules\Symfony\UndefinedOptionRule + - PHPStan\Rules\Symfony\InvalidOptionDefaultValueRule services: + # console resolver + - + factory: PHPStan\Symfony\ConsoleApplicationResolver + arguments: [%symfony.console_application_loader%] + # service map symfony.serviceMapFactory: class: PHPStan\Symfony\ServiceMapFactory @@ -55,3 +66,33 @@ services: - factory: PHPStan\Type\Symfony\EnvelopeReturnTypeExtension tags: [phpstan.broker.dynamicMethodReturnTypeExtension] + + # InputInterface::getArgument() return type + - + factory: PHPStan\Type\Symfony\InputInterfaceGetArgumentDynamicReturnTypeExtension + tags: [phpstan.broker.dynamicMethodReturnTypeExtension] + + # InputInterface::hasArgument() type specification + - + factory: PHPStan\Type\Symfony\ArgumentTypeSpecifyingExtension + tags: [phpstan.typeSpecifier.methodTypeSpecifyingExtension] + + # InputInterface::hasArgument() return type + - + factory: PHPStan\Type\Symfony\InputInterfaceHasArgumentDynamicReturnTypeExtension + tags: [phpstan.broker.dynamicMethodReturnTypeExtension] + + # InputInterface::getOption() return type + - + factory: PHPStan\Type\Symfony\InputInterfaceGetOptionDynamicReturnTypeExtension + tags: [phpstan.broker.dynamicMethodReturnTypeExtension] + + # InputInterface::hasOption() type specification + - + factory: PHPStan\Type\Symfony\OptionTypeSpecifyingExtension + tags: [phpstan.typeSpecifier.methodTypeSpecifyingExtension] + + # InputInterface::hasOption() return type + - + factory: PHPStan\Type\Symfony\InputInterfaceHasOptionDynamicReturnTypeExtension + tags: [phpstan.broker.dynamicMethodReturnTypeExtension] diff --git a/phpstan.neon b/phpstan.neon index 66c99036..4dd356bf 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -7,6 +7,8 @@ parameters: excludes_analyse: - */tests/tmp/* - */tests/*/Example*.php + - */tests/*/console_application_loader.php + - */tests/*/envelope_all.php - */tests/*/header_bag_get.php - */tests/*/request_get_content.php - */tests/*/serializer.php diff --git a/src/Rules/Symfony/ContainerInterfacePrivateServiceRule.php b/src/Rules/Symfony/ContainerInterfacePrivateServiceRule.php index b9e978a6..9e4d6aa5 100644 --- a/src/Rules/Symfony/ContainerInterfacePrivateServiceRule.php +++ b/src/Rules/Symfony/ContainerInterfacePrivateServiceRule.php @@ -62,7 +62,7 @@ public function processNode(Node $node, Scope $scope): array return []; } - $serviceId = ServiceMap::getServiceIdFromNode($node->args[0]->value, $scope); + $serviceId = $this->serviceMap::getServiceIdFromNode($node->args[0]->value, $scope); if ($serviceId !== null) { $service = $this->serviceMap->getService($serviceId); if ($service !== null && !$service->isPublic()) { diff --git a/src/Rules/Symfony/ContainerInterfaceUnknownServiceRule.php b/src/Rules/Symfony/ContainerInterfaceUnknownServiceRule.php index c7265b81..0e147982 100644 --- a/src/Rules/Symfony/ContainerInterfaceUnknownServiceRule.php +++ b/src/Rules/Symfony/ContainerInterfaceUnknownServiceRule.php @@ -59,7 +59,7 @@ public function processNode(Node $node, Scope $scope): array return []; } - $serviceId = ServiceMap::getServiceIdFromNode($node->args[0]->value, $scope); + $serviceId = $this->serviceMap::getServiceIdFromNode($node->args[0]->value, $scope); if ($serviceId !== null) { $service = $this->serviceMap->getService($serviceId); $serviceIdType = $scope->getType($node->args[0]->value); diff --git a/src/Rules/Symfony/InvalidArgumentDefaultValueRule.php b/src/Rules/Symfony/InvalidArgumentDefaultValueRule.php new file mode 100644 index 00000000..9da3d536 --- /dev/null +++ b/src/Rules/Symfony/InvalidArgumentDefaultValueRule.php @@ -0,0 +1,78 @@ +isSuperTypeOf($scope->getType($node->var))->yes()) { + return []; + } + if (!$node->name instanceof Node\Identifier || $node->name->name !== 'addArgument') { + return []; + } + if (!isset($node->args[3])) { + return []; + } + + $modeType = isset($node->args[1]) ? $scope->getType($node->args[1]->value) : new NullType(); + if ($modeType instanceof NullType) { + $modeType = new ConstantIntegerType(2); // InputArgument::OPTIONAL + } + $modeTypes = TypeUtils::getConstantScalars($modeType); + if (count($modeTypes) !== 1) { + return []; + } + if (!$modeTypes[0] instanceof ConstantIntegerType) { + return []; + } + $mode = $modeTypes[0]->getValue(); + + $defaultType = $scope->getType($node->args[3]->value); + + // not an array + if (($mode & 4) !== 4 && !(new UnionType([new StringType(), new NullType()]))->isSuperTypeOf($defaultType)->yes()) { + return [sprintf('Parameter #4 $default of method Symfony\Component\Console\Command\Command::addArgument() expects string|null, %s given.', $defaultType->describe(VerbosityLevel::typeOnly()))]; + } + + // is array + if (($mode & 4) === 4 && !(new UnionType([new ArrayType(new IntegerType(), new StringType()), new NullType()]))->isSuperTypeOf($defaultType)->yes()) { + return [sprintf('Parameter #4 $default of method Symfony\Component\Console\Command\Command::addArgument() expects array|null, %s given.', $defaultType->describe(VerbosityLevel::typeOnly()))]; + } + + return []; + } + +} diff --git a/src/Rules/Symfony/InvalidOptionDefaultValueRule.php b/src/Rules/Symfony/InvalidOptionDefaultValueRule.php new file mode 100644 index 00000000..8af4da19 --- /dev/null +++ b/src/Rules/Symfony/InvalidOptionDefaultValueRule.php @@ -0,0 +1,86 @@ +isSuperTypeOf($scope->getType($node->var))->yes()) { + return []; + } + if (!$node->name instanceof Node\Identifier || $node->name->name !== 'addOption') { + return []; + } + if (!isset($node->args[4])) { + return []; + } + + $modeType = isset($node->args[2]) ? $scope->getType($node->args[2]->value) : new NullType(); + if ($modeType instanceof NullType) { + $modeType = new ConstantIntegerType(1); // InputOption::VALUE_NONE + } + $modeTypes = TypeUtils::getConstantScalars($modeType); + if (count($modeTypes) !== 1) { + return []; + } + if (!$modeTypes[0] instanceof ConstantIntegerType) { + return []; + } + $mode = $modeTypes[0]->getValue(); + + $defaultType = $scope->getType($node->args[4]->value); + + // not an array + if (($mode & 8) !== 8) { + $checkType = new UnionType([new StringType(), new IntegerType(), new NullType()]); + if (($mode & 4) === 4) { // https://symfony.com/doc/current/console/input.html#options-with-optional-arguments + $checkType = TypeCombinator::union($checkType, new ConstantBooleanType(false)); + } + if (!$checkType->isSuperTypeOf($defaultType)->yes()) { + return [sprintf('Parameter #5 $default of method Symfony\Component\Console\Command\Command::addOption() expects %s, %s given.', $checkType->describe(VerbosityLevel::typeOnly()), $defaultType->describe(VerbosityLevel::typeOnly()))]; + } + } + + // is array + if (($mode & 8) === 8 && !(new UnionType([new ArrayType(new IntegerType(), new StringType()), new NullType()]))->isSuperTypeOf($defaultType)->yes()) { + return [sprintf('Parameter #5 $default of method Symfony\Component\Console\Command\Command::addOption() expects array|null, %s given.', $defaultType->describe(VerbosityLevel::typeOnly()))]; + } + + return []; + } + +} diff --git a/src/Rules/Symfony/UndefinedArgumentRule.php b/src/Rules/Symfony/UndefinedArgumentRule.php new file mode 100644 index 00000000..39af2ba0 --- /dev/null +++ b/src/Rules/Symfony/UndefinedArgumentRule.php @@ -0,0 +1,90 @@ +consoleApplicationResolver = $consoleApplicationResolver; + $this->printer = $printer; + } + + public function getNodeType(): string + { + return MethodCall::class; + } + + /** + * @param \PhpParser\Node $node + * @param \PHPStan\Analyser\Scope $scope + * @return (string|\PHPStan\Rules\RuleError)[] errors + */ + public function processNode(Node $node, Scope $scope): array + { + if (!$node instanceof MethodCall) { + throw new ShouldNotHappenException(); + }; + + $classReflection = $scope->getClassReflection(); + if ($classReflection === null) { + throw new ShouldNotHappenException(); + } + + if (!(new ObjectType('Symfony\Component\Console\Command\Command'))->isSuperTypeOf(new ObjectType($classReflection->getName()))->yes()) { + return []; + } + if (!(new ObjectType('Symfony\Component\Console\Input\InputInterface'))->isSuperTypeOf($scope->getType($node->var))->yes()) { + return []; + } + if (!$node->name instanceof Node\Identifier || $node->name->name !== 'getArgument') { + return []; + } + if (!isset($node->args[0])) { + return []; + } + + $argType = $scope->getType($node->args[0]->value); + $argStrings = TypeUtils::getConstantStrings($argType); + if (count($argStrings) !== 1) { + return []; + } + $argName = $argStrings[0]->getValue(); + + $errors = []; + foreach ($this->consoleApplicationResolver->findCommands($classReflection) as $name => $command) { + try { + $command->getDefinition()->getArgument($argName); + } catch (InvalidArgumentException $e) { + if ($scope->getType(Helper::createMarkerNode($node->var, $argType, $this->printer))->equals($argType)) { + continue; + } + $errors[] = sprintf('Command "%s" does not define argument "%s".', $name, $argName); + } + } + + return $errors; + } + +} diff --git a/src/Rules/Symfony/UndefinedOptionRule.php b/src/Rules/Symfony/UndefinedOptionRule.php new file mode 100644 index 00000000..656d13fd --- /dev/null +++ b/src/Rules/Symfony/UndefinedOptionRule.php @@ -0,0 +1,90 @@ +consoleApplicationResolver = $consoleApplicationResolver; + $this->printer = $printer; + } + + public function getNodeType(): string + { + return MethodCall::class; + } + + /** + * @param \PhpParser\Node $node + * @param \PHPStan\Analyser\Scope $scope + * @return (string|\PHPStan\Rules\RuleError)[] errors + */ + public function processNode(Node $node, Scope $scope): array + { + if (!$node instanceof MethodCall) { + throw new ShouldNotHappenException(); + }; + + $classReflection = $scope->getClassReflection(); + if ($classReflection === null) { + throw new ShouldNotHappenException(); + } + + if (!(new ObjectType('Symfony\Component\Console\Command\Command'))->isSuperTypeOf(new ObjectType($classReflection->getName()))->yes()) { + return []; + } + if (!(new ObjectType('Symfony\Component\Console\Input\InputInterface'))->isSuperTypeOf($scope->getType($node->var))->yes()) { + return []; + } + if (!$node->name instanceof Node\Identifier || $node->name->name !== 'getOption') { + return []; + } + if (!isset($node->args[0])) { + return []; + } + + $optType = $scope->getType($node->args[0]->value); + $optStrings = TypeUtils::getConstantStrings($optType); + if (count($optStrings) !== 1) { + return []; + } + $optName = $optStrings[0]->getValue(); + + $errors = []; + foreach ($this->consoleApplicationResolver->findCommands($classReflection) as $name => $command) { + try { + $command->getDefinition()->getOption($optName); + } catch (InvalidArgumentException $e) { + if ($scope->getType(Helper::createMarkerNode($node->var, $optType, $this->printer))->equals($optType)) { + continue; + } + $errors[] = sprintf('Command "%s" does not define option "%s".', $name, $optName); + } + } + + return $errors; + } + +} diff --git a/src/Symfony/ConsoleApplicationResolver.php b/src/Symfony/ConsoleApplicationResolver.php new file mode 100644 index 00000000..9a684d17 --- /dev/null +++ b/src/Symfony/ConsoleApplicationResolver.php @@ -0,0 +1,66 @@ +consoleApplication = $this->loadConsoleApplication($consoleApplicationLoader); + } + + /** + * @return \Symfony\Component\Console\Application|null + * @phpcsSuppress SlevomatCodingStandard.TypeHints.TypeHintDeclaration.MissingReturnTypeHint + */ + private function loadConsoleApplication(string $consoleApplicationLoader) + { + if (!file_exists($consoleApplicationLoader) + || !is_readable($consoleApplicationLoader) + ) { + throw new ShouldNotHappenException(); + } + + return require $consoleApplicationLoader; + } + + /** + * @return \Symfony\Component\Console\Command\Command[] + */ + public function findCommands(ClassReflection $classReflection): array + { + if ($this->consoleApplication === null) { + return []; + } + + $classType = new ObjectType($classReflection->getName()); + if (!(new ObjectType('Symfony\Component\Console\Command\Command'))->isSuperTypeOf($classType)->yes()) { + return []; + } + + $commands = []; + foreach ($this->consoleApplication->all() as $name => $command) { + if (!$classType->isSuperTypeOf(new ObjectType(get_class($command)))->yes()) { + continue; + } + $commands[$name] = $command; + } + + return $commands; + } + +} diff --git a/src/Symfony/DefaultServiceMap.php b/src/Symfony/DefaultServiceMap.php new file mode 100644 index 00000000..6339b450 --- /dev/null +++ b/src/Symfony/DefaultServiceMap.php @@ -0,0 +1,43 @@ +services = $services; + } + + /** + * @return \PHPStan\Symfony\ServiceDefinition[] + */ + public function getServices(): array + { + return $this->services; + } + + public function getService(string $id): ?ServiceDefinition + { + return $this->services[$id] ?? null; + } + + public static function getServiceIdFromNode(Expr $node, Scope $scope): ?string + { + $strings = TypeUtils::getConstantStrings($scope->getType($node)); + return count($strings) === 1 ? $strings[0]->getValue() : null; + } + +} diff --git a/src/Symfony/FakeServiceMap.php b/src/Symfony/FakeServiceMap.php new file mode 100644 index 00000000..d05fe8ed --- /dev/null +++ b/src/Symfony/FakeServiceMap.php @@ -0,0 +1,29 @@ +services = $services; - } - /** * @return \PHPStan\Symfony\ServiceDefinition[] */ - public function getServices(): array - { - return $this->services; - } + public function getServices(): array; - public function getService(string $id): ?ServiceDefinition - { - return $this->services[$id] ?? null; - } + public function getService(string $id): ?ServiceDefinition; - public static function getServiceIdFromNode(Expr $node, Scope $scope): ?string - { - $strings = TypeUtils::getConstantStrings($scope->getType($node)); - return count($strings) === 1 ? $strings[0]->getValue() : null; - } + public static function getServiceIdFromNode(Expr $node, Scope $scope): ?string; } diff --git a/src/Symfony/XmlServiceMapFactory.php b/src/Symfony/XmlServiceMapFactory.php index de40d695..0f575891 100644 --- a/src/Symfony/XmlServiceMapFactory.php +++ b/src/Symfony/XmlServiceMapFactory.php @@ -10,16 +10,20 @@ final class XmlServiceMapFactory implements ServiceMapFactory { - /** @var string */ + /** @var string|null */ private $containerXml; - public function __construct(string $containerXml) + public function __construct(?string $containerXml) { $this->containerXml = $containerXml; } public function create(): ServiceMap { + if ($this->containerXml === null) { + return new FakeServiceMap(); + } + $fileContents = file_get_contents($this->containerXml); if ($fileContents === false) { throw new XmlContainerNotExistsException(sprintf('Container %s does not exist or cannot be parsed', $this->containerXml)); @@ -70,7 +74,7 @@ public function create(): ServiceMap ); } - return new ServiceMap($services); + return new DefaultServiceMap($services); } } diff --git a/src/Type/Symfony/ArgumentTypeSpecifyingExtension.php b/src/Type/Symfony/ArgumentTypeSpecifyingExtension.php new file mode 100644 index 00000000..edf5574c --- /dev/null +++ b/src/Type/Symfony/ArgumentTypeSpecifyingExtension.php @@ -0,0 +1,57 @@ +printer = $printer; + } + + public function getClass(): string + { + return 'Symfony\Component\Console\Input\InputInterface'; + } + + public function isMethodSupported(MethodReflection $methodReflection, MethodCall $node, TypeSpecifierContext $context): bool + { + return $methodReflection->getName() === 'hasArgument' && !$context->null(); + } + + public function specifyTypes(MethodReflection $methodReflection, MethodCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + if (!isset($node->args[0])) { + return new SpecifiedTypes(); + } + $argType = $scope->getType($node->args[0]->value); + return $this->typeSpecifier->create( + Helper::createMarkerNode($node->var, $argType, $this->printer), + $argType, + $context + ); + } + + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void + { + $this->typeSpecifier = $typeSpecifier; + } + +} diff --git a/src/Type/Symfony/InputInterfaceGetArgumentDynamicReturnTypeExtension.php b/src/Type/Symfony/InputInterfaceGetArgumentDynamicReturnTypeExtension.php new file mode 100644 index 00000000..235a1dce --- /dev/null +++ b/src/Type/Symfony/InputInterfaceGetArgumentDynamicReturnTypeExtension.php @@ -0,0 +1,85 @@ +consoleApplicationResolver = $consoleApplicationResolver; + } + + public function getClass(): string + { + return 'Symfony\Component\Console\Input\InputInterface'; + } + + public function isMethodSupported(MethodReflection $methodReflection): bool + { + return $methodReflection->getName() === 'getArgument'; + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type + { + $defaultReturnType = ParametersAcceptorSelector::selectFromArgs($scope, $methodCall->args, $methodReflection->getVariants())->getReturnType(); + + if (!isset($methodCall->args[0])) { + return $defaultReturnType; + } + + $classReflection = $scope->getClassReflection(); + if ($classReflection === null) { + throw new ShouldNotHappenException(); + } + + $argStrings = TypeUtils::getConstantStrings($scope->getType($methodCall->args[0]->value)); + if (count($argStrings) !== 1) { + return $defaultReturnType; + } + $argName = $argStrings[0]->getValue(); + + $argTypes = []; + foreach ($this->consoleApplicationResolver->findCommands($classReflection) as $command) { + try { + $argument = $command->getDefinition()->getArgument($argName); + if ($argument->isArray()) { + $argType = new ArrayType(new IntegerType(), new StringType()); + if (!$argument->isRequired() && $argument->getDefault() !== []) { + $argType = TypeCombinator::union($argType, $scope->getTypeFromValue($argument->getDefault())); + } + } else { + $argType = new StringType(); + if (!$argument->isRequired()) { + $argType = TypeCombinator::union($argType, $scope->getTypeFromValue($argument->getDefault())); + } + } + $argTypes[] = $argType; + } catch (InvalidArgumentException $e) { + // noop + } + } + + return count($argTypes) > 0 ? TypeCombinator::union(...$argTypes) : $defaultReturnType; + } + +} diff --git a/src/Type/Symfony/InputInterfaceGetOptionDynamicReturnTypeExtension.php b/src/Type/Symfony/InputInterfaceGetOptionDynamicReturnTypeExtension.php new file mode 100644 index 00000000..f340a5d7 --- /dev/null +++ b/src/Type/Symfony/InputInterfaceGetOptionDynamicReturnTypeExtension.php @@ -0,0 +1,88 @@ +consoleApplicationResolver = $consoleApplicationResolver; + } + + public function getClass(): string + { + return 'Symfony\Component\Console\Input\InputInterface'; + } + + public function isMethodSupported(MethodReflection $methodReflection): bool + { + return $methodReflection->getName() === 'getOption'; + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type + { + $defaultReturnType = ParametersAcceptorSelector::selectFromArgs($scope, $methodCall->args, $methodReflection->getVariants())->getReturnType(); + + if (!isset($methodCall->args[0])) { + return $defaultReturnType; + } + + $classReflection = $scope->getClassReflection(); + if ($classReflection === null) { + throw new ShouldNotHappenException(); + } + + $optStrings = TypeUtils::getConstantStrings($scope->getType($methodCall->args[0]->value)); + if (count($optStrings) !== 1) { + return $defaultReturnType; + } + $optName = $optStrings[0]->getValue(); + + $optTypes = []; + foreach ($this->consoleApplicationResolver->findCommands($classReflection) as $command) { + try { + $option = $command->getDefinition()->getOption($optName); + if (!$option->acceptValue()) { + $optType = new BooleanType(); + } else { + $optType = TypeCombinator::union(new StringType(), new IntegerType(), new NullType()); + if ($option->isValueRequired() && ($option->isArray() || $option->getDefault() !== null)) { + $optType = TypeCombinator::removeNull($optType); + } + if ($option->isArray()) { + $optType = new ArrayType(new IntegerType(), TypeCombinator::remove($optType, new IntegerType())); + } + $optType = TypeCombinator::union($optType, $scope->getTypeFromValue($option->getDefault())); + } + $optTypes[] = $optType; + } catch (InvalidArgumentException $e) { + // noop + } + } + + return count($optTypes) > 0 ? TypeCombinator::union(...$optTypes) : $defaultReturnType; + } + +} diff --git a/src/Type/Symfony/InputInterfaceHasArgumentDynamicReturnTypeExtension.php b/src/Type/Symfony/InputInterfaceHasArgumentDynamicReturnTypeExtension.php new file mode 100644 index 00000000..b0e471df --- /dev/null +++ b/src/Type/Symfony/InputInterfaceHasArgumentDynamicReturnTypeExtension.php @@ -0,0 +1,77 @@ +consoleApplicationResolver = $consoleApplicationResolver; + } + + public function getClass(): string + { + return 'Symfony\Component\Console\Input\InputInterface'; + } + + public function isMethodSupported(MethodReflection $methodReflection): bool + { + return $methodReflection->getName() === 'hasArgument'; + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type + { + $defaultReturnType = new BooleanType(); + + if (!isset($methodCall->args[0])) { + return $defaultReturnType; + } + + $classReflection = $scope->getClassReflection(); + if ($classReflection === null) { + throw new ShouldNotHappenException(); + } + + $argStrings = TypeUtils::getConstantStrings($scope->getType($methodCall->args[0]->value)); + if (count($argStrings) !== 1) { + return $defaultReturnType; + } + $argName = $argStrings[0]->getValue(); + + $returnTypes = []; + foreach ($this->consoleApplicationResolver->findCommands($classReflection) as $command) { + try { + $command->getDefinition()->getArgument($argName); + $returnTypes[] = true; + } catch (InvalidArgumentException $e) { + $returnTypes[] = false; + } + } + + if (count($returnTypes) === 0) { + return $defaultReturnType; + } + + $returnTypes = array_unique($returnTypes); + return count($returnTypes) === 1 ? new ConstantBooleanType($returnTypes[0]) : $defaultReturnType; + } + +} diff --git a/src/Type/Symfony/InputInterfaceHasOptionDynamicReturnTypeExtension.php b/src/Type/Symfony/InputInterfaceHasOptionDynamicReturnTypeExtension.php new file mode 100644 index 00000000..3c5a1a3d --- /dev/null +++ b/src/Type/Symfony/InputInterfaceHasOptionDynamicReturnTypeExtension.php @@ -0,0 +1,77 @@ +consoleApplicationResolver = $consoleApplicationResolver; + } + + public function getClass(): string + { + return 'Symfony\Component\Console\Input\InputInterface'; + } + + public function isMethodSupported(MethodReflection $methodReflection): bool + { + return $methodReflection->getName() === 'hasOption'; + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type + { + $defaultReturnType = new BooleanType(); + + if (!isset($methodCall->args[0])) { + return $defaultReturnType; + } + + $classReflection = $scope->getClassReflection(); + if ($classReflection === null) { + throw new ShouldNotHappenException(); + } + + $optStrings = TypeUtils::getConstantStrings($scope->getType($methodCall->args[0]->value)); + if (count($optStrings) !== 1) { + return $defaultReturnType; + } + $optName = $optStrings[0]->getValue(); + + $returnTypes = []; + foreach ($this->consoleApplicationResolver->findCommands($classReflection) as $command) { + try { + $command->getDefinition()->getOption($optName); + $returnTypes[] = true; + } catch (InvalidArgumentException $e) { + $returnTypes[] = false; + } + } + + if (count($returnTypes) === 0) { + return $defaultReturnType; + } + + $returnTypes = array_unique($returnTypes); + return count($returnTypes) === 1 ? new ConstantBooleanType($returnTypes[0]) : $defaultReturnType; + } + +} diff --git a/src/Type/Symfony/OptionTypeSpecifyingExtension.php b/src/Type/Symfony/OptionTypeSpecifyingExtension.php new file mode 100644 index 00000000..aaff84c0 --- /dev/null +++ b/src/Type/Symfony/OptionTypeSpecifyingExtension.php @@ -0,0 +1,57 @@ +printer = $printer; + } + + public function getClass(): string + { + return 'Symfony\Component\Console\Input\InputInterface'; + } + + public function isMethodSupported(MethodReflection $methodReflection, MethodCall $node, TypeSpecifierContext $context): bool + { + return $methodReflection->getName() === 'hasOption' && !$context->null(); + } + + public function specifyTypes(MethodReflection $methodReflection, MethodCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + if (!isset($node->args[0])) { + return new SpecifiedTypes(); + } + $argType = $scope->getType($node->args[0]->value); + return $this->typeSpecifier->create( + Helper::createMarkerNode($node->var, $argType, $this->printer), + $argType, + $context + ); + } + + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void + { + $this->typeSpecifier = $typeSpecifier; + } + +} diff --git a/src/Type/Symfony/ServiceDynamicReturnTypeExtension.php b/src/Type/Symfony/ServiceDynamicReturnTypeExtension.php index 48e8aacf..6b2f6f84 100644 --- a/src/Type/Symfony/ServiceDynamicReturnTypeExtension.php +++ b/src/Type/Symfony/ServiceDynamicReturnTypeExtension.php @@ -24,13 +24,13 @@ final class ServiceDynamicReturnTypeExtension implements DynamicMethodReturnType private $constantHassers; /** @var \PHPStan\Symfony\ServiceMap */ - private $symfonyServiceMap; + private $serviceMap; public function __construct(string $className, bool $constantHassers, ServiceMap $symfonyServiceMap) { $this->className = $className; $this->constantHassers = $constantHassers; - $this->symfonyServiceMap = $symfonyServiceMap; + $this->serviceMap = $symfonyServiceMap; } public function getClass(): string @@ -65,9 +65,9 @@ private function getGetTypeFromMethodCall( return $returnType; } - $serviceId = ServiceMap::getServiceIdFromNode($methodCall->args[0]->value, $scope); + $serviceId = $this->serviceMap::getServiceIdFromNode($methodCall->args[0]->value, $scope); if ($serviceId !== null) { - $service = $this->symfonyServiceMap->getService($serviceId); + $service = $this->serviceMap->getService($serviceId); if ($service !== null && !$service->isSynthetic()) { return new ObjectType($service->getClass() ?? $serviceId); } @@ -87,9 +87,9 @@ private function getHasTypeFromMethodCall( return $returnType; } - $serviceId = ServiceMap::getServiceIdFromNode($methodCall->args[0]->value, $scope); + $serviceId = $this->serviceMap::getServiceIdFromNode($methodCall->args[0]->value, $scope); if ($serviceId !== null) { - $service = $this->symfonyServiceMap->getService($serviceId); + $service = $this->serviceMap->getService($serviceId); return new ConstantBooleanType($service !== null && $service->isPublic()); } diff --git a/tests/Rules/Symfony/ContainerInterfacePrivateServiceRuleFakeTest.php b/tests/Rules/Symfony/ContainerInterfacePrivateServiceRuleFakeTest.php new file mode 100644 index 00000000..db79af6c --- /dev/null +++ b/tests/Rules/Symfony/ContainerInterfacePrivateServiceRuleFakeTest.php @@ -0,0 +1,59 @@ +create()); + } + + public function testGetPrivateService(): void + { + $this->analyse( + [ + __DIR__ . '/ExampleController.php', + ], + [] + ); + } + + public function testGetPrivateServiceInLegacyServiceSubscriber(): void + { + if (!interface_exists('Symfony\\Component\\DependencyInjection\\ServiceSubscriberInterface')) { + self::markTestSkipped('The test needs Symfony\Component\DependencyInjection\ServiceSubscriberInterface class.'); + } + + $this->analyse( + [ + __DIR__ . '/ExampleLegacyServiceSubscriber.php', + __DIR__ . '/ExampleLegacyServiceSubscriberFromAbstractController.php', + __DIR__ . '/ExampleLegacyServiceSubscriberFromLegacyController.php', + ], + [] + ); + } + + public function testGetPrivateServiceInServiceSubscriber(): void + { + if (!interface_exists('Symfony\Contracts\Service\ServiceSubscriberInterface')) { + self::markTestSkipped('The test needs Symfony\Contracts\Service\ServiceSubscriberInterface class.'); + } + + $this->analyse( + [ + __DIR__ . '/ExampleServiceSubscriber.php', + __DIR__ . '/ExampleServiceSubscriberFromAbstractController.php', + __DIR__ . '/ExampleServiceSubscriberFromLegacyController.php', + ], + [] + ); + } + +} diff --git a/tests/Rules/Symfony/ContainerInterfaceUnknownServiceRuleFakeTest.php b/tests/Rules/Symfony/ContainerInterfaceUnknownServiceRuleFakeTest.php new file mode 100644 index 00000000..cc0f2977 --- /dev/null +++ b/tests/Rules/Symfony/ContainerInterfaceUnknownServiceRuleFakeTest.php @@ -0,0 +1,40 @@ +create(), new Standard()); + } + + /** + * @return \PHPStan\Type\MethodTypeSpecifyingExtension[] + */ + protected function getMethodTypeSpecifyingExtensions(): array + { + return [ + new ServiceTypeSpecifyingExtension(Controller::class, new Standard()), + ]; + } + + public function testGetPrivateService(): void + { + $this->analyse( + [ + __DIR__ . '/ExampleController.php', + ], + [] + ); + } + +} diff --git a/tests/Rules/Symfony/ExampleCommand.php b/tests/Rules/Symfony/ExampleCommand.php new file mode 100644 index 00000000..ba3fe7bf --- /dev/null +++ b/tests/Rules/Symfony/ExampleCommand.php @@ -0,0 +1,53 @@ +setName('example-rule'); + + $this->addArgument('arg'); + + $this->addArgument('foo1', null, '', null); + $this->addArgument('bar1', null, '', ''); + $this->addArgument('baz1', null, '', 1); + $this->addArgument('quz1', null, '', ['']); + + $this->addArgument('quz2', InputArgument::IS_ARRAY, '', ['a' => 'b']); + + $this->addOption('aaa'); + + $this->addOption('b', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_OPTIONAL, '', [1]); + $this->addOption('c', null, InputOption::VALUE_OPTIONAL, '', 1); + $this->addOption('d', null, InputOption::VALUE_OPTIONAL, '', false); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $input->getArgument('arg'); + $input->getArgument('undefined'); + + if ($input->hasArgument('guarded')) { + $input->getArgument('guarded'); + } + + $input->getOption('aaa'); + $input->getOption('bbb'); + + if ($input->hasOption('ccc')) { + $input->getOption('ccc'); + } + + return 0; + } + +} diff --git a/tests/Rules/Symfony/InvalidArgumentDefaultValueRuleTest.php b/tests/Rules/Symfony/InvalidArgumentDefaultValueRuleTest.php new file mode 100644 index 00000000..cc06fc0b --- /dev/null +++ b/tests/Rules/Symfony/InvalidArgumentDefaultValueRuleTest.php @@ -0,0 +1,39 @@ +analyse( + [ + __DIR__ . '/ExampleCommand.php', + ], + [ + [ + 'Parameter #4 $default of method Symfony\Component\Console\Command\Command::addArgument() expects string|null, int given.', + 22, + ], + [ + 'Parameter #4 $default of method Symfony\Component\Console\Command\Command::addArgument() expects string|null, array given.', + 23, + ], + [ + 'Parameter #4 $default of method Symfony\Component\Console\Command\Command::addArgument() expects array|null, array given.', + 25, + ], + ] + ); + } + +} diff --git a/tests/Rules/Symfony/InvalidOptionDefaultValueRuleTest.php b/tests/Rules/Symfony/InvalidOptionDefaultValueRuleTest.php new file mode 100644 index 00000000..66ea42f7 --- /dev/null +++ b/tests/Rules/Symfony/InvalidOptionDefaultValueRuleTest.php @@ -0,0 +1,31 @@ +analyse( + [ + __DIR__ . '/ExampleCommand.php', + ], + [ + [ + 'Parameter #5 $default of method Symfony\Component\Console\Command\Command::addOption() expects array|null, array given.', + 29, + ], + ] + ); + } + +} diff --git a/tests/Rules/Symfony/UndefinedArgumentRuleTest.php b/tests/Rules/Symfony/UndefinedArgumentRuleTest.php new file mode 100644 index 00000000..842f70a7 --- /dev/null +++ b/tests/Rules/Symfony/UndefinedArgumentRuleTest.php @@ -0,0 +1,44 @@ +analyse( + [ + __DIR__ . '/ExampleCommand.php', + ], + [ + [ + 'Command "example-rule" does not define argument "undefined".', + 37, + ], + ] + ); + } + +} diff --git a/tests/Rules/Symfony/UndefinedOptionRuleTest.php b/tests/Rules/Symfony/UndefinedOptionRuleTest.php new file mode 100644 index 00000000..1c70e36f --- /dev/null +++ b/tests/Rules/Symfony/UndefinedOptionRuleTest.php @@ -0,0 +1,44 @@ +analyse( + [ + __DIR__ . '/ExampleCommand.php', + ], + [ + [ + 'Command "example-rule" does not define option "bbb".', + 44, + ], + ] + ); + } + +} diff --git a/tests/Rules/Symfony/console_application_loader.php b/tests/Rules/Symfony/console_application_loader.php new file mode 100644 index 00000000..05f8ed51 --- /dev/null +++ b/tests/Rules/Symfony/console_application_loader.php @@ -0,0 +1,10 @@ +add(new ExampleCommand()); +return $application; diff --git a/tests/Symfony/ServiceMapTest.php b/tests/Symfony/DefaultServiceMapTest.php similarity index 98% rename from tests/Symfony/ServiceMapTest.php rename to tests/Symfony/DefaultServiceMapTest.php index 5bbbe412..5a1c1801 100644 --- a/tests/Symfony/ServiceMapTest.php +++ b/tests/Symfony/DefaultServiceMapTest.php @@ -5,7 +5,7 @@ use Iterator; use PHPUnit\Framework\TestCase; -final class ServiceMapTest extends TestCase +final class DefaultServiceMapTest extends TestCase { /** diff --git a/tests/Symfony/NeonTest.php b/tests/Symfony/NeonTest.php index a189b043..18b90a9e 100644 --- a/tests/Symfony/NeonTest.php +++ b/tests/Symfony/NeonTest.php @@ -25,8 +25,8 @@ public function testExtensionNeon(): void $class = $loader->load(function (Compiler $compiler): void { $compiler->addExtension('rules', new RulesExtension()); $compiler->addConfig(['parameters' => ['rootDir' => __DIR__]]); - $compiler->loadConfig(__DIR__ . '/config.neon'); $compiler->loadConfig(__DIR__ . '/../../extension.neon'); + $compiler->loadConfig(__DIR__ . '/config.neon'); }, $key); /** @var \Nette\DI\Container $container */ $container = new $class(); @@ -36,12 +36,13 @@ public function testExtensionNeon(): void 'symfony' => [ 'container_xml_path' => __DIR__ . '/container.xml', 'constant_hassers' => true, + 'console_application_loader' => null, ], ], $container->getParameters()); - self::assertCount(2, $container->findByTag('phpstan.rules.rule')); - self::assertCount(7, $container->findByTag('phpstan.broker.dynamicMethodReturnTypeExtension')); - self::assertCount(3, $container->findByTag('phpstan.typeSpecifier.methodTypeSpecifyingExtension')); + self::assertCount(6, $container->findByTag('phpstan.rules.rule')); + self::assertCount(11, $container->findByTag('phpstan.broker.dynamicMethodReturnTypeExtension')); + self::assertCount(5, $container->findByTag('phpstan.typeSpecifier.methodTypeSpecifyingExtension')); self::assertInstanceOf(ServiceMap::class, $container->getByType(ServiceMap::class)); } diff --git a/tests/Type/Symfony/ExampleACommand.php b/tests/Type/Symfony/ExampleACommand.php new file mode 100644 index 00000000..4ba27410 --- /dev/null +++ b/tests/Type/Symfony/ExampleACommand.php @@ -0,0 +1,21 @@ +setName('example-a'); + + $this->addArgument('aaa', null, '', 'aaa'); + $this->addArgument('both'); + $this->addArgument('diff', null, '', 'ddd'); + $this->addArgument('arr', InputArgument::IS_ARRAY, '', ['arr']); + } + +} diff --git a/tests/Type/Symfony/ExampleBCommand.php b/tests/Type/Symfony/ExampleBCommand.php new file mode 100644 index 00000000..b6b00dc2 --- /dev/null +++ b/tests/Type/Symfony/ExampleBCommand.php @@ -0,0 +1,20 @@ +setName('example-b'); + + $this->addArgument('both'); + $this->addArgument('bbb', null, '', 'bbb'); + $this->addArgument('diff', InputArgument::IS_ARRAY, '', ['diff']); + } + +} diff --git a/tests/Type/Symfony/ExampleBaseCommand.php b/tests/Type/Symfony/ExampleBaseCommand.php new file mode 100644 index 00000000..b058ee2c --- /dev/null +++ b/tests/Type/Symfony/ExampleBaseCommand.php @@ -0,0 +1,31 @@ +addArgument('base'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $base = $input->getArgument('base'); + $aaa = $input->getArgument('aaa'); + $bbb = $input->getArgument('bbb'); + $diff = $input->getArgument('diff'); + $arr = $input->getArgument('arr'); + $both = $input->getArgument('both'); + + die; + } + +} diff --git a/tests/Type/Symfony/ExampleOptionCommand.php b/tests/Type/Symfony/ExampleOptionCommand.php new file mode 100644 index 00000000..c18d55e2 --- /dev/null +++ b/tests/Type/Symfony/ExampleOptionCommand.php @@ -0,0 +1,46 @@ +setName('example-option'); + + $this->addOption('a', null, InputOption::VALUE_NONE); + $this->addOption('b', null, InputOption::VALUE_OPTIONAL); + $this->addOption('c', null, InputOption::VALUE_REQUIRED); + $this->addOption('d', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_OPTIONAL); + $this->addOption('e', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED); + + $this->addOption('bb', null, InputOption::VALUE_OPTIONAL, '', 1); + $this->addOption('cc', null, InputOption::VALUE_REQUIRED, '', 1); + $this->addOption('dd', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_OPTIONAL, '', [1]); + $this->addOption('ee', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, '', [1]); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $a = $input->getOption('a'); + $b = $input->getOption('b'); + $c = $input->getOption('c'); + $d = $input->getOption('d'); + $e = $input->getOption('e'); + + $bb = $input->getOption('bb'); + $cc = $input->getOption('cc'); + $dd = $input->getOption('dd'); + $ee = $input->getOption('ee'); + + die; + } + +} diff --git a/tests/Type/Symfony/InputInterfaceGetArgumentDynamicReturnTypeExtensionTest.php b/tests/Type/Symfony/InputInterfaceGetArgumentDynamicReturnTypeExtensionTest.php new file mode 100644 index 00000000..216321f4 --- /dev/null +++ b/tests/Type/Symfony/InputInterfaceGetArgumentDynamicReturnTypeExtensionTest.php @@ -0,0 +1,34 @@ +processFile( + __DIR__ . '/ExampleBaseCommand.php', + $expression, + $type, + new InputInterfaceGetArgumentDynamicReturnTypeExtension(new ConsoleApplicationResolver(__DiR__ . '/console_application_loader.php')) + ); + } + + public function argumentTypesProvider(): Iterator + { + yield ['$base', 'string|null']; + yield ['$aaa', 'string']; + yield ['$bbb', 'string']; + yield ['$diff', 'array|string']; + yield ['$arr', 'array']; + yield ['$both', 'string|null']; + } + +} diff --git a/tests/Type/Symfony/InputInterfaceGetOptionDynamicReturnTypeExtensionTest.php b/tests/Type/Symfony/InputInterfaceGetOptionDynamicReturnTypeExtensionTest.php new file mode 100644 index 00000000..6e949ce6 --- /dev/null +++ b/tests/Type/Symfony/InputInterfaceGetOptionDynamicReturnTypeExtensionTest.php @@ -0,0 +1,38 @@ +processFile( + __DIR__ . '/ExampleOptionCommand.php', + $expression, + $type, + new InputInterfaceGetOptionDynamicReturnTypeExtension(new ConsoleApplicationResolver(__DiR__ . '/console_application_loader.php')) + ); + } + + public function argumentTypesProvider(): Iterator + { + yield ['$a', 'bool']; + yield ['$b', 'int|string|null']; + yield ['$c', 'int|string|null']; + yield ['$d', 'array']; + yield ['$e', 'array']; + + yield ['$bb', 'int|string|null']; + yield ['$cc', 'int|string']; + yield ['$dd', 'array']; + yield ['$ee', 'array']; + } + +} diff --git a/tests/Type/Symfony/ServiceDynamicReturnTypeExtensionTest.php b/tests/Type/Symfony/ServiceDynamicReturnTypeExtensionTest.php index a1ef5100..9d77d459 100644 --- a/tests/Type/Symfony/ServiceDynamicReturnTypeExtensionTest.php +++ b/tests/Type/Symfony/ServiceDynamicReturnTypeExtensionTest.php @@ -12,45 +12,57 @@ final class ServiceDynamicReturnTypeExtensionTest extends ExtensionTestCase /** * @dataProvider servicesProvider */ - public function testServices(string $expression, string $type): void + public function testServices(string $expression, string $type, ?string $container): void { $this->processFile( __DIR__ . '/ExampleController.php', $expression, $type, - new ServiceDynamicReturnTypeExtension(Controller::class, true, (new XmlServiceMapFactory(__DIR__ . '/container.xml'))->create()) + new ServiceDynamicReturnTypeExtension(Controller::class, true, (new XmlServiceMapFactory($container))->create()) ); } public function servicesProvider(): Iterator { - yield ['$service1', 'Foo']; - yield ['$service2', 'object']; - yield ['$service3', 'object']; - yield ['$service4', 'object']; - yield ['$has1', 'true']; - yield ['$has2', 'false']; - yield ['$has3', 'bool']; - yield ['$has4', 'bool']; + yield ['$service1', 'Foo', __DIR__ . '/container.xml']; + yield ['$service2', 'object', __DIR__ . '/container.xml']; + yield ['$service3', 'object', __DIR__ . '/container.xml']; + yield ['$service4', 'object', __DIR__ . '/container.xml']; + yield ['$has1', 'true', __DIR__ . '/container.xml']; + yield ['$has2', 'false', __DIR__ . '/container.xml']; + yield ['$has3', 'bool', __DIR__ . '/container.xml']; + yield ['$has4', 'bool', __DIR__ . '/container.xml']; + + yield ['$service1', 'object', null]; + yield ['$service2', 'object', null]; + yield ['$service3', 'object', null]; + yield ['$service4', 'object', null]; + yield ['$has1', 'bool', null]; + yield ['$has2', 'bool', null]; + yield ['$has3', 'bool', null]; + yield ['$has4', 'bool', null]; } /** * @dataProvider constantHassersOffProvider */ - public function testConstantHassersOff(string $expression, string $type): void + public function testConstantHassersOff(string $expression, string $type, ?string $container): void { $this->processFile( __DIR__ . '/ExampleController.php', $expression, $type, - new ServiceDynamicReturnTypeExtension(Controller::class, false, (new XmlServiceMapFactory(__DIR__ . '/container.xml'))->create()) + new ServiceDynamicReturnTypeExtension(Controller::class, false, (new XmlServiceMapFactory($container))->create()) ); } public function constantHassersOffProvider(): Iterator { - yield ['$has1', 'bool']; - yield ['$has2', 'bool']; + yield ['$has1', 'bool', __DIR__ . '/container.xml']; + yield ['$has2', 'bool', __DIR__ . '/container.xml']; + + yield ['$has1', 'bool', null]; + yield ['$has2', 'bool', null]; } } diff --git a/tests/Type/Symfony/console_application_loader.php b/tests/Type/Symfony/console_application_loader.php new file mode 100644 index 00000000..524bc159 --- /dev/null +++ b/tests/Type/Symfony/console_application_loader.php @@ -0,0 +1,14 @@ +add(new ExampleACommand()); +$application->add(new ExampleBCommand()); +$application->add(new ExampleOptionCommand()); +return $application;