From e9e5578d92a854f57e467e5cb3328eda7ed11541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Unger?= Date: Tue, 2 Apr 2019 23:31:32 +0200 Subject: [PATCH 1/6] Support for console InputInterface::getArgument() --- composer.json | 5 +- extension.neon | 18 ++++ phpstan.neon | 2 + .../InvalidArgumentDefaultValueRule.php | 78 ++++++++++++++++ src/Rules/Symfony/UndefinedArgumentRule.php | 90 +++++++++++++++++++ src/Symfony/ConsoleApplicationResolver.php | 66 ++++++++++++++ .../ArgumentTypeSpecifyingExtension.php | 57 ++++++++++++ ...eGetArgumentDynamicReturnTypeExtension.php | 85 ++++++++++++++++++ tests/Rules/Symfony/ExampleCommand.php | 39 ++++++++ .../InvalidArgumentDefaultValueRuleTest.php | 39 ++++++++ .../Symfony/UndefinedArgumentRuleTest.php | 44 +++++++++ .../Symfony/console_application_loader.php | 10 +++ tests/Symfony/NeonTest.php | 7 +- tests/Type/Symfony/ExampleACommand.php | 21 +++++ tests/Type/Symfony/ExampleBCommand.php | 20 +++++ tests/Type/Symfony/ExampleBaseCommand.php | 31 +++++++ ...ArgumentDynamicReturnTypeExtensionTest.php | 34 +++++++ .../Symfony/console_application_loader.php | 12 +++ 18 files changed, 653 insertions(+), 5 deletions(-) create mode 100644 src/Rules/Symfony/InvalidArgumentDefaultValueRule.php create mode 100644 src/Rules/Symfony/UndefinedArgumentRule.php create mode 100644 src/Symfony/ConsoleApplicationResolver.php create mode 100644 src/Type/Symfony/ArgumentTypeSpecifyingExtension.php create mode 100644 src/Type/Symfony/InputInterfaceGetArgumentDynamicReturnTypeExtension.php create mode 100644 tests/Rules/Symfony/ExampleCommand.php create mode 100644 tests/Rules/Symfony/InvalidArgumentDefaultValueRuleTest.php create mode 100644 tests/Rules/Symfony/UndefinedArgumentRuleTest.php create mode 100644 tests/Rules/Symfony/console_application_loader.php create mode 100644 tests/Type/Symfony/ExampleACommand.php create mode 100644 tests/Type/Symfony/ExampleBCommand.php create mode 100644 tests/Type/Symfony/ExampleBaseCommand.php create mode 100644 tests/Type/Symfony/InputInterfaceGetArgumentDynamicReturnTypeExtensionTest.php create mode 100644 tests/Type/Symfony/console_application_loader.php 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..80d3a0b3 100644 --- a/extension.neon +++ b/extension.neon @@ -1,12 +1,20 @@ parameters: symfony: constant_hassers: true + console_application_loader: null rules: - PHPStan\Rules\Symfony\ContainerInterfacePrivateServiceRule - PHPStan\Rules\Symfony\ContainerInterfaceUnknownServiceRule + - PHPStan\Rules\Symfony\UndefinedArgumentRule + - PHPStan\Rules\Symfony\InvalidArgumentDefaultValueRule services: + # console resolver + - + factory: PHPStan\Symfony\ConsoleApplicationResolver + arguments: [%symfony.console_application_loader%] + # service map symfony.serviceMapFactory: class: PHPStan\Symfony\ServiceMapFactory @@ -55,3 +63,13 @@ 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] 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/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/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/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/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/tests/Rules/Symfony/ExampleCommand.php b/tests/Rules/Symfony/ExampleCommand.php new file mode 100644 index 00000000..20befa44 --- /dev/null +++ b/tests/Rules/Symfony/ExampleCommand.php @@ -0,0 +1,39 @@ +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']); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $input->getArgument('arg'); + $input->getArgument('undefined'); + + if ($input->hasArgument('guarded')) { + $input->getArgument('guarded'); + } + + return 0; + } + +} diff --git a/tests/Rules/Symfony/InvalidArgumentDefaultValueRuleTest.php b/tests/Rules/Symfony/InvalidArgumentDefaultValueRuleTest.php new file mode 100644 index 00000000..2c9e3783 --- /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.', + 21, + ], + [ + 'Parameter #4 $default of method Symfony\Component\Console\Command\Command::addArgument() expects string|null, array given.', + 22, + ], + [ + 'Parameter #4 $default of method Symfony\Component\Console\Command\Command::addArgument() expects array|null, array given.', + 24, + ], + ] + ); + } + +} diff --git a/tests/Rules/Symfony/UndefinedArgumentRuleTest.php b/tests/Rules/Symfony/UndefinedArgumentRuleTest.php new file mode 100644 index 00000000..13dc007b --- /dev/null +++ b/tests/Rules/Symfony/UndefinedArgumentRuleTest.php @@ -0,0 +1,44 @@ +analyse( + [ + __DIR__ . '/ExampleCommand.php', + ], + [ + [ + 'Command "example-rule" does not define argument "undefined".', + 30, + ], + ] + ); + } + +} 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/NeonTest.php b/tests/Symfony/NeonTest.php index a189b043..694158bf 100644 --- a/tests/Symfony/NeonTest.php +++ b/tests/Symfony/NeonTest.php @@ -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(4, $container->findByTag('phpstan.rules.rule')); + self::assertCount(8, $container->findByTag('phpstan.broker.dynamicMethodReturnTypeExtension')); + self::assertCount(4, $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/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/console_application_loader.php b/tests/Type/Symfony/console_application_loader.php new file mode 100644 index 00000000..ffb1f769 --- /dev/null +++ b/tests/Type/Symfony/console_application_loader.php @@ -0,0 +1,12 @@ +add(new ExampleACommand()); +$application->add(new ExampleBCommand()); +return $application; From e0cf8cd7738daf9576755c4c944273da5bf85f3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Unger?= Date: Mon, 8 Apr 2019 13:27:32 +0200 Subject: [PATCH 2/6] Support for console InputInterface::getOption() --- extension.neon | 12 +++ .../Symfony/InvalidOptionDefaultValueRule.php | 86 ++++++++++++++++++ src/Rules/Symfony/UndefinedOptionRule.php | 90 +++++++++++++++++++ ...aceGetOptionDynamicReturnTypeExtension.php | 88 ++++++++++++++++++ .../Symfony/OptionTypeSpecifyingExtension.php | 57 ++++++++++++ tests/Rules/Symfony/ExampleCommand.php | 14 +++ .../InvalidArgumentDefaultValueRuleTest.php | 6 +- .../InvalidOptionDefaultValueRuleTest.php | 35 ++++++++ .../Symfony/UndefinedArgumentRuleTest.php | 2 +- .../Rules/Symfony/UndefinedOptionRuleTest.php | 44 +++++++++ tests/Symfony/NeonTest.php | 6 +- tests/Type/Symfony/ExampleOptionCommand.php | 46 ++++++++++ ...etOptionDynamicReturnTypeExtensionTest.php | 38 ++++++++ .../Symfony/console_application_loader.php | 2 + 14 files changed, 519 insertions(+), 7 deletions(-) create mode 100644 src/Rules/Symfony/InvalidOptionDefaultValueRule.php create mode 100644 src/Rules/Symfony/UndefinedOptionRule.php create mode 100644 src/Type/Symfony/InputInterfaceGetOptionDynamicReturnTypeExtension.php create mode 100644 src/Type/Symfony/OptionTypeSpecifyingExtension.php create mode 100644 tests/Rules/Symfony/InvalidOptionDefaultValueRuleTest.php create mode 100644 tests/Rules/Symfony/UndefinedOptionRuleTest.php create mode 100644 tests/Type/Symfony/ExampleOptionCommand.php create mode 100644 tests/Type/Symfony/InputInterfaceGetOptionDynamicReturnTypeExtensionTest.php diff --git a/extension.neon b/extension.neon index 80d3a0b3..3ad5a43d 100644 --- a/extension.neon +++ b/extension.neon @@ -8,6 +8,8 @@ rules: - PHPStan\Rules\Symfony\ContainerInterfaceUnknownServiceRule - PHPStan\Rules\Symfony\UndefinedArgumentRule - PHPStan\Rules\Symfony\InvalidArgumentDefaultValueRule + - PHPStan\Rules\Symfony\UndefinedOptionRule + - PHPStan\Rules\Symfony\InvalidOptionDefaultValueRule services: # console resolver @@ -73,3 +75,13 @@ services: - factory: PHPStan\Type\Symfony\ArgumentTypeSpecifyingExtension tags: [phpstan.typeSpecifier.methodTypeSpecifyingExtension] + + # 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] diff --git a/src/Rules/Symfony/InvalidOptionDefaultValueRule.php b/src/Rules/Symfony/InvalidOptionDefaultValueRule.php new file mode 100644 index 00000000..3966a285 --- /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 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/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/Type/Symfony/InputInterfaceGetOptionDynamicReturnTypeExtension.php b/src/Type/Symfony/InputInterfaceGetOptionDynamicReturnTypeExtension.php new file mode 100644 index 00000000..a25a6a0c --- /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 NullType()); + if ($option->isValueRequired() && ($option->isArray() || $option->getDefault() !== null)) { + $optType = TypeCombinator::removeNull($optType); + } + if ($option->isArray()) { + $optType = new ArrayType(new IntegerType(), $optType); + } + $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/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/tests/Rules/Symfony/ExampleCommand.php b/tests/Rules/Symfony/ExampleCommand.php index 20befa44..ba3fe7bf 100644 --- a/tests/Rules/Symfony/ExampleCommand.php +++ b/tests/Rules/Symfony/ExampleCommand.php @@ -5,6 +5,7 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; final class ExampleCommand extends Command @@ -22,6 +23,12 @@ protected function configure(): void $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 @@ -33,6 +40,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int $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 index 2c9e3783..cc06fc0b 100644 --- a/tests/Rules/Symfony/InvalidArgumentDefaultValueRuleTest.php +++ b/tests/Rules/Symfony/InvalidArgumentDefaultValueRuleTest.php @@ -22,15 +22,15 @@ public function testGetArgument(): void [ [ 'Parameter #4 $default of method Symfony\Component\Console\Command\Command::addArgument() expects string|null, int given.', - 21, + 22, ], [ 'Parameter #4 $default of method Symfony\Component\Console\Command\Command::addArgument() expects string|null, array given.', - 22, + 23, ], [ 'Parameter #4 $default of method Symfony\Component\Console\Command\Command::addArgument() expects array|null, array given.', - 24, + 25, ], ] ); diff --git a/tests/Rules/Symfony/InvalidOptionDefaultValueRuleTest.php b/tests/Rules/Symfony/InvalidOptionDefaultValueRuleTest.php new file mode 100644 index 00000000..e19e0ca8 --- /dev/null +++ b/tests/Rules/Symfony/InvalidOptionDefaultValueRuleTest.php @@ -0,0 +1,35 @@ +analyse( + [ + __DIR__ . '/ExampleCommand.php', + ], + [ + [ + 'Parameter #5 $default of method Symfony\Component\Console\Command\Command::addOption() expects array|null, array given.', + 29, + ], + [ + 'Parameter #5 $default of method Symfony\Component\Console\Command\Command::addOption() expects string|false|null, int given.', + 30, + ], + ] + ); + } + +} diff --git a/tests/Rules/Symfony/UndefinedArgumentRuleTest.php b/tests/Rules/Symfony/UndefinedArgumentRuleTest.php index 13dc007b..842f70a7 100644 --- a/tests/Rules/Symfony/UndefinedArgumentRuleTest.php +++ b/tests/Rules/Symfony/UndefinedArgumentRuleTest.php @@ -35,7 +35,7 @@ public function testGetArgument(): void [ [ 'Command "example-rule" does not define argument "undefined".', - 30, + 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/Symfony/NeonTest.php b/tests/Symfony/NeonTest.php index 694158bf..b95b9fb9 100644 --- a/tests/Symfony/NeonTest.php +++ b/tests/Symfony/NeonTest.php @@ -40,9 +40,9 @@ public function testExtensionNeon(): void ], ], $container->getParameters()); - self::assertCount(4, $container->findByTag('phpstan.rules.rule')); - self::assertCount(8, $container->findByTag('phpstan.broker.dynamicMethodReturnTypeExtension')); - self::assertCount(4, $container->findByTag('phpstan.typeSpecifier.methodTypeSpecifyingExtension')); + self::assertCount(6, $container->findByTag('phpstan.rules.rule')); + self::assertCount(9, $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/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/InputInterfaceGetOptionDynamicReturnTypeExtensionTest.php b/tests/Type/Symfony/InputInterfaceGetOptionDynamicReturnTypeExtensionTest.php new file mode 100644 index 00000000..09b3ba6d --- /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', 'string|null']; + yield ['$c', '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/console_application_loader.php b/tests/Type/Symfony/console_application_loader.php index ffb1f769..524bc159 100644 --- a/tests/Type/Symfony/console_application_loader.php +++ b/tests/Type/Symfony/console_application_loader.php @@ -2,6 +2,7 @@ use PHPStan\Type\Symfony\ExampleACommand; use PHPStan\Type\Symfony\ExampleBCommand; +use PHPStan\Type\Symfony\ExampleOptionCommand; use Symfony\Component\Console\Application; require_once __DIR__ . '/../../../vendor/autoload.php'; @@ -9,4 +10,5 @@ $application = new Application(); $application->add(new ExampleACommand()); $application->add(new ExampleBCommand()); +$application->add(new ExampleOptionCommand()); return $application; From 0cb3c0b000c5aaf8e9f40f79cc4365e790761ff1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Unger?= Date: Mon, 8 Apr 2019 14:21:08 +0200 Subject: [PATCH 3/6] Update readme --- README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/README.md b/README.md index 87f25054..bf66b5b5 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()` and `::getOption` ## 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); +``` From a4d85023552990928d4e6229b23665a8576c26b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Unger?= Date: Tue, 9 Apr 2019 10:24:10 +0200 Subject: [PATCH 4/6] Support for InputInterface::hasArgument and ::hasOption --- README.md | 2 +- extension.neon | 10 +++ ...eHasArgumentDynamicReturnTypeExtension.php | 77 +++++++++++++++++++ ...aceHasOptionDynamicReturnTypeExtension.php | 77 +++++++++++++++++++ tests/Symfony/NeonTest.php | 2 +- 5 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 src/Type/Symfony/InputInterfaceHasArgumentDynamicReturnTypeExtension.php create mode 100644 src/Type/Symfony/InputInterfaceHasOptionDynamicReturnTypeExtension.php diff --git a/README.md b/README.md index bf66b5b5..492c8795 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ This extension provides following features: * 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()` and `::getOption` +* Optionally correct return types for `InputInterface::getArgument()`, `::getOption`, `::hasArgument`, and `::hasOption`. ## Usage diff --git a/extension.neon b/extension.neon index 3ad5a43d..8bbfb0f2 100644 --- a/extension.neon +++ b/extension.neon @@ -76,6 +76,11 @@ services: 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 @@ -85,3 +90,8 @@ services: - 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/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/tests/Symfony/NeonTest.php b/tests/Symfony/NeonTest.php index b95b9fb9..e4b47c3a 100644 --- a/tests/Symfony/NeonTest.php +++ b/tests/Symfony/NeonTest.php @@ -41,7 +41,7 @@ public function testExtensionNeon(): void ], $container->getParameters()); self::assertCount(6, $container->findByTag('phpstan.rules.rule')); - self::assertCount(9, $container->findByTag('phpstan.broker.dynamicMethodReturnTypeExtension')); + self::assertCount(11, $container->findByTag('phpstan.broker.dynamicMethodReturnTypeExtension')); self::assertCount(5, $container->findByTag('phpstan.typeSpecifier.methodTypeSpecifyingExtension')); self::assertInstanceOf(ServiceMap::class, $container->getByType(ServiceMap::class)); } From 6cac146547749214a8eeb73e1537b98258923a92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Unger?= Date: Mon, 15 Apr 2019 11:43:01 +0200 Subject: [PATCH 5/6] getOption() can return int --- src/Rules/Symfony/InvalidOptionDefaultValueRule.php | 2 +- .../InputInterfaceGetOptionDynamicReturnTypeExtension.php | 4 ++-- tests/Rules/Symfony/InvalidOptionDefaultValueRuleTest.php | 4 ---- .../InputInterfaceGetOptionDynamicReturnTypeExtensionTest.php | 4 ++-- 4 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/Rules/Symfony/InvalidOptionDefaultValueRule.php b/src/Rules/Symfony/InvalidOptionDefaultValueRule.php index 3966a285..8af4da19 100644 --- a/src/Rules/Symfony/InvalidOptionDefaultValueRule.php +++ b/src/Rules/Symfony/InvalidOptionDefaultValueRule.php @@ -66,7 +66,7 @@ public function processNode(Node $node, Scope $scope): array // not an array if (($mode & 8) !== 8) { - $checkType = new UnionType([new StringType(), new NullType()]); + $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)); } diff --git a/src/Type/Symfony/InputInterfaceGetOptionDynamicReturnTypeExtension.php b/src/Type/Symfony/InputInterfaceGetOptionDynamicReturnTypeExtension.php index a25a6a0c..f340a5d7 100644 --- a/src/Type/Symfony/InputInterfaceGetOptionDynamicReturnTypeExtension.php +++ b/src/Type/Symfony/InputInterfaceGetOptionDynamicReturnTypeExtension.php @@ -67,12 +67,12 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method if (!$option->acceptValue()) { $optType = new BooleanType(); } else { - $optType = TypeCombinator::union(new StringType(), new NullType()); + $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(), $optType); + $optType = new ArrayType(new IntegerType(), TypeCombinator::remove($optType, new IntegerType())); } $optType = TypeCombinator::union($optType, $scope->getTypeFromValue($option->getDefault())); } diff --git a/tests/Rules/Symfony/InvalidOptionDefaultValueRuleTest.php b/tests/Rules/Symfony/InvalidOptionDefaultValueRuleTest.php index e19e0ca8..66ea42f7 100644 --- a/tests/Rules/Symfony/InvalidOptionDefaultValueRuleTest.php +++ b/tests/Rules/Symfony/InvalidOptionDefaultValueRuleTest.php @@ -24,10 +24,6 @@ public function testGetArgument(): void 'Parameter #5 $default of method Symfony\Component\Console\Command\Command::addOption() expects array|null, array given.', 29, ], - [ - 'Parameter #5 $default of method Symfony\Component\Console\Command\Command::addOption() expects string|false|null, int given.', - 30, - ], ] ); } diff --git a/tests/Type/Symfony/InputInterfaceGetOptionDynamicReturnTypeExtensionTest.php b/tests/Type/Symfony/InputInterfaceGetOptionDynamicReturnTypeExtensionTest.php index 09b3ba6d..6e949ce6 100644 --- a/tests/Type/Symfony/InputInterfaceGetOptionDynamicReturnTypeExtensionTest.php +++ b/tests/Type/Symfony/InputInterfaceGetOptionDynamicReturnTypeExtensionTest.php @@ -24,8 +24,8 @@ public function testArgumentTypes(string $expression, string $type): void public function argumentTypesProvider(): Iterator { yield ['$a', 'bool']; - yield ['$b', 'string|null']; - yield ['$c', 'string|null']; + yield ['$b', 'int|string|null']; + yield ['$c', 'int|string|null']; yield ['$d', 'array']; yield ['$e', 'array']; From b8e94b1779d325a50e1194ba91c9822566de9bb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Unger?= Date: Wed, 10 Apr 2019 11:24:13 +0200 Subject: [PATCH 6/6] Make container xml config option nullable --- extension.neon | 1 + .../ContainerInterfacePrivateServiceRule.php | 2 +- .../ContainerInterfaceUnknownServiceRule.php | 2 +- src/Symfony/DefaultServiceMap.php | 43 ++++++++++++++ src/Symfony/FakeServiceMap.php | 29 +++++++++ src/Symfony/ServiceMap.php | 31 ++-------- src/Symfony/XmlServiceMapFactory.php | 10 +++- .../ServiceDynamicReturnTypeExtension.php | 12 ++-- ...nerInterfacePrivateServiceRuleFakeTest.php | 59 +++++++++++++++++++ ...nerInterfaceUnknownServiceRuleFakeTest.php | 40 +++++++++++++ ...eMapTest.php => DefaultServiceMapTest.php} | 2 +- tests/Symfony/NeonTest.php | 2 +- .../ServiceDynamicReturnTypeExtensionTest.php | 40 ++++++++----- 13 files changed, 219 insertions(+), 54 deletions(-) create mode 100644 src/Symfony/DefaultServiceMap.php create mode 100644 src/Symfony/FakeServiceMap.php create mode 100644 tests/Rules/Symfony/ContainerInterfacePrivateServiceRuleFakeTest.php create mode 100644 tests/Rules/Symfony/ContainerInterfaceUnknownServiceRuleFakeTest.php rename tests/Symfony/{ServiceMapTest.php => DefaultServiceMapTest.php} (98%) diff --git a/extension.neon b/extension.neon index 8bbfb0f2..a98009f5 100644 --- a/extension.neon +++ b/extension.neon @@ -1,5 +1,6 @@ parameters: symfony: + container_xml_path: null constant_hassers: true console_application_loader: null 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/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/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/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 e4b47c3a..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(); 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]; } }