Skip to content

Commit 142a5d5

Browse files
authored
Merge pull request #45 from phpstan/commands
Console commands
2 parents 9c7e936 + b8e94b1 commit 142a5d5

40 files changed

+1565
-59
lines changed

README.md

+21
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@ This extension provides following features:
1111
* Provides correct return type for `ContainerInterface::get()` and `::has()` methods.
1212
* Provides correct return type for `Controller::get()` and `::has()` methods.
1313
* Provides correct return type for `Request::getContent()` method based on the `$asResource` parameter.
14+
* Provides correct return type for `HeaderBag::get()` method based on the `$first` parameter.
15+
* Provides correct return type for `Envelope::all()` method based on the `$stampFqcn` parameter.
1416
* Notifies you when you try to get an unregistered service from the container.
1517
* Notifies you when you try to get a private service from the container.
18+
* Optionally correct return types for `InputInterface::getArgument()`, `::getOption`, `::hasArgument`, and `::hasOption`.
1619

1720
## Usage
1821

@@ -55,3 +58,21 @@ parameters:
5558
```
5659

5760
Be aware that it may hide genuine errors in your application.
61+
62+
## Console command analysis
63+
64+
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().
65+
66+
```
67+
parameters:
68+
symfony:
69+
console_application_loader: tests/console-application.php
70+
```
71+
72+
For example, in a Symfony project, `console-application.php` would look something like this:
73+
74+
```php
75+
require dirname(__DIR__).'/../config/bootstrap.php';
76+
$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
77+
return new \Symfony\Bundle\FrameworkBundle\Console\Application($kernel);
78+
```

composer.json

+3-2
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@
3333
"phpstan/phpstan-phpunit": "^0.11",
3434
"symfony/framework-bundle": "^3.0 || ^4.0",
3535
"squizlabs/php_codesniffer": "^3.3.2",
36-
"symfony/serializer": "^3|^4",
37-
"symfony/messenger": "^4.2"
36+
"symfony/serializer": "^3.0 || ^4.0",
37+
"symfony/messenger": "^4.2",
38+
"symfony/console": "^3.0 || ^4.0"
3839
},
3940
"conflict": {
4041
"symfony/framework-bundle": "<3.0"

extension.neon

+41
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
11
parameters:
22
symfony:
3+
container_xml_path: null
34
constant_hassers: true
5+
console_application_loader: null
46

57
rules:
68
- PHPStan\Rules\Symfony\ContainerInterfacePrivateServiceRule
79
- PHPStan\Rules\Symfony\ContainerInterfaceUnknownServiceRule
10+
- PHPStan\Rules\Symfony\UndefinedArgumentRule
11+
- PHPStan\Rules\Symfony\InvalidArgumentDefaultValueRule
12+
- PHPStan\Rules\Symfony\UndefinedOptionRule
13+
- PHPStan\Rules\Symfony\InvalidOptionDefaultValueRule
814

915
services:
16+
# console resolver
17+
-
18+
factory: PHPStan\Symfony\ConsoleApplicationResolver
19+
arguments: [%symfony.console_application_loader%]
20+
1021
# service map
1122
symfony.serviceMapFactory:
1223
class: PHPStan\Symfony\ServiceMapFactory
@@ -55,3 +66,33 @@ services:
5566
-
5667
factory: PHPStan\Type\Symfony\EnvelopeReturnTypeExtension
5768
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
69+
70+
# InputInterface::getArgument() return type
71+
-
72+
factory: PHPStan\Type\Symfony\InputInterfaceGetArgumentDynamicReturnTypeExtension
73+
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
74+
75+
# InputInterface::hasArgument() type specification
76+
-
77+
factory: PHPStan\Type\Symfony\ArgumentTypeSpecifyingExtension
78+
tags: [phpstan.typeSpecifier.methodTypeSpecifyingExtension]
79+
80+
# InputInterface::hasArgument() return type
81+
-
82+
factory: PHPStan\Type\Symfony\InputInterfaceHasArgumentDynamicReturnTypeExtension
83+
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
84+
85+
# InputInterface::getOption() return type
86+
-
87+
factory: PHPStan\Type\Symfony\InputInterfaceGetOptionDynamicReturnTypeExtension
88+
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
89+
90+
# InputInterface::hasOption() type specification
91+
-
92+
factory: PHPStan\Type\Symfony\OptionTypeSpecifyingExtension
93+
tags: [phpstan.typeSpecifier.methodTypeSpecifyingExtension]
94+
95+
# InputInterface::hasOption() return type
96+
-
97+
factory: PHPStan\Type\Symfony\InputInterfaceHasOptionDynamicReturnTypeExtension
98+
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]

phpstan.neon

+2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ parameters:
77
excludes_analyse:
88
- */tests/tmp/*
99
- */tests/*/Example*.php
10+
- */tests/*/console_application_loader.php
11+
- */tests/*/envelope_all.php
1012
- */tests/*/header_bag_get.php
1113
- */tests/*/request_get_content.php
1214
- */tests/*/serializer.php

src/Rules/Symfony/ContainerInterfacePrivateServiceRule.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ public function processNode(Node $node, Scope $scope): array
6262
return [];
6363
}
6464

65-
$serviceId = ServiceMap::getServiceIdFromNode($node->args[0]->value, $scope);
65+
$serviceId = $this->serviceMap::getServiceIdFromNode($node->args[0]->value, $scope);
6666
if ($serviceId !== null) {
6767
$service = $this->serviceMap->getService($serviceId);
6868
if ($service !== null && !$service->isPublic()) {

src/Rules/Symfony/ContainerInterfaceUnknownServiceRule.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ public function processNode(Node $node, Scope $scope): array
5959
return [];
6060
}
6161

62-
$serviceId = ServiceMap::getServiceIdFromNode($node->args[0]->value, $scope);
62+
$serviceId = $this->serviceMap::getServiceIdFromNode($node->args[0]->value, $scope);
6363
if ($serviceId !== null) {
6464
$service = $this->serviceMap->getService($serviceId);
6565
$serviceIdType = $scope->getType($node->args[0]->value);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Symfony;
4+
5+
use PhpParser\Node;
6+
use PhpParser\Node\Expr\MethodCall;
7+
use PHPStan\Analyser\Scope;
8+
use PHPStan\Rules\Rule;
9+
use PHPStan\ShouldNotHappenException;
10+
use PHPStan\Type\ArrayType;
11+
use PHPStan\Type\Constant\ConstantIntegerType;
12+
use PHPStan\Type\IntegerType;
13+
use PHPStan\Type\NullType;
14+
use PHPStan\Type\ObjectType;
15+
use PHPStan\Type\StringType;
16+
use PHPStan\Type\TypeUtils;
17+
use PHPStan\Type\UnionType;
18+
use PHPStan\Type\VerbosityLevel;
19+
use function sprintf;
20+
21+
final class InvalidArgumentDefaultValueRule implements Rule
22+
{
23+
24+
public function getNodeType(): string
25+
{
26+
return MethodCall::class;
27+
}
28+
29+
/**
30+
* @param \PhpParser\Node $node
31+
* @param \PHPStan\Analyser\Scope $scope
32+
* @return (string|\PHPStan\Rules\RuleError)[] errors
33+
*/
34+
public function processNode(Node $node, Scope $scope): array
35+
{
36+
if (!$node instanceof MethodCall) {
37+
throw new ShouldNotHappenException();
38+
};
39+
40+
if (!(new ObjectType('Symfony\Component\Console\Command\Command'))->isSuperTypeOf($scope->getType($node->var))->yes()) {
41+
return [];
42+
}
43+
if (!$node->name instanceof Node\Identifier || $node->name->name !== 'addArgument') {
44+
return [];
45+
}
46+
if (!isset($node->args[3])) {
47+
return [];
48+
}
49+
50+
$modeType = isset($node->args[1]) ? $scope->getType($node->args[1]->value) : new NullType();
51+
if ($modeType instanceof NullType) {
52+
$modeType = new ConstantIntegerType(2); // InputArgument::OPTIONAL
53+
}
54+
$modeTypes = TypeUtils::getConstantScalars($modeType);
55+
if (count($modeTypes) !== 1) {
56+
return [];
57+
}
58+
if (!$modeTypes[0] instanceof ConstantIntegerType) {
59+
return [];
60+
}
61+
$mode = $modeTypes[0]->getValue();
62+
63+
$defaultType = $scope->getType($node->args[3]->value);
64+
65+
// not an array
66+
if (($mode & 4) !== 4 && !(new UnionType([new StringType(), new NullType()]))->isSuperTypeOf($defaultType)->yes()) {
67+
return [sprintf('Parameter #4 $default of method Symfony\Component\Console\Command\Command::addArgument() expects string|null, %s given.', $defaultType->describe(VerbosityLevel::typeOnly()))];
68+
}
69+
70+
// is array
71+
if (($mode & 4) === 4 && !(new UnionType([new ArrayType(new IntegerType(), new StringType()), new NullType()]))->isSuperTypeOf($defaultType)->yes()) {
72+
return [sprintf('Parameter #4 $default of method Symfony\Component\Console\Command\Command::addArgument() expects array<int, string>|null, %s given.', $defaultType->describe(VerbosityLevel::typeOnly()))];
73+
}
74+
75+
return [];
76+
}
77+
78+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Symfony;
4+
5+
use PhpParser\Node;
6+
use PhpParser\Node\Expr\MethodCall;
7+
use PHPStan\Analyser\Scope;
8+
use PHPStan\Rules\Rule;
9+
use PHPStan\ShouldNotHappenException;
10+
use PHPStan\Type\ArrayType;
11+
use PHPStan\Type\Constant\ConstantBooleanType;
12+
use PHPStan\Type\Constant\ConstantIntegerType;
13+
use PHPStan\Type\IntegerType;
14+
use PHPStan\Type\NullType;
15+
use PHPStan\Type\ObjectType;
16+
use PHPStan\Type\StringType;
17+
use PHPStan\Type\TypeCombinator;
18+
use PHPStan\Type\TypeUtils;
19+
use PHPStan\Type\UnionType;
20+
use PHPStan\Type\VerbosityLevel;
21+
use function sprintf;
22+
23+
final class InvalidOptionDefaultValueRule implements Rule
24+
{
25+
26+
public function getNodeType(): string
27+
{
28+
return MethodCall::class;
29+
}
30+
31+
/**
32+
* @param \PhpParser\Node $node
33+
* @param \PHPStan\Analyser\Scope $scope
34+
* @return (string|\PHPStan\Rules\RuleError)[] errors
35+
*/
36+
public function processNode(Node $node, Scope $scope): array
37+
{
38+
if (!$node instanceof MethodCall) {
39+
throw new ShouldNotHappenException();
40+
};
41+
42+
if (!(new ObjectType('Symfony\Component\Console\Command\Command'))->isSuperTypeOf($scope->getType($node->var))->yes()) {
43+
return [];
44+
}
45+
if (!$node->name instanceof Node\Identifier || $node->name->name !== 'addOption') {
46+
return [];
47+
}
48+
if (!isset($node->args[4])) {
49+
return [];
50+
}
51+
52+
$modeType = isset($node->args[2]) ? $scope->getType($node->args[2]->value) : new NullType();
53+
if ($modeType instanceof NullType) {
54+
$modeType = new ConstantIntegerType(1); // InputOption::VALUE_NONE
55+
}
56+
$modeTypes = TypeUtils::getConstantScalars($modeType);
57+
if (count($modeTypes) !== 1) {
58+
return [];
59+
}
60+
if (!$modeTypes[0] instanceof ConstantIntegerType) {
61+
return [];
62+
}
63+
$mode = $modeTypes[0]->getValue();
64+
65+
$defaultType = $scope->getType($node->args[4]->value);
66+
67+
// not an array
68+
if (($mode & 8) !== 8) {
69+
$checkType = new UnionType([new StringType(), new IntegerType(), new NullType()]);
70+
if (($mode & 4) === 4) { // https://symfony.com/doc/current/console/input.html#options-with-optional-arguments
71+
$checkType = TypeCombinator::union($checkType, new ConstantBooleanType(false));
72+
}
73+
if (!$checkType->isSuperTypeOf($defaultType)->yes()) {
74+
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()))];
75+
}
76+
}
77+
78+
// is array
79+
if (($mode & 8) === 8 && !(new UnionType([new ArrayType(new IntegerType(), new StringType()), new NullType()]))->isSuperTypeOf($defaultType)->yes()) {
80+
return [sprintf('Parameter #5 $default of method Symfony\Component\Console\Command\Command::addOption() expects array<int, string>|null, %s given.', $defaultType->describe(VerbosityLevel::typeOnly()))];
81+
}
82+
83+
return [];
84+
}
85+
86+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Symfony;
4+
5+
use InvalidArgumentException;
6+
use PhpParser\Node;
7+
use PhpParser\Node\Expr\MethodCall;
8+
use PhpParser\PrettyPrinter\Standard;
9+
use PHPStan\Analyser\Scope;
10+
use PHPStan\Rules\Rule;
11+
use PHPStan\ShouldNotHappenException;
12+
use PHPStan\Symfony\ConsoleApplicationResolver;
13+
use PHPStan\Type\ObjectType;
14+
use PHPStan\Type\Symfony\Helper;
15+
use PHPStan\Type\TypeUtils;
16+
use function count;
17+
use function sprintf;
18+
19+
final class UndefinedArgumentRule implements Rule
20+
{
21+
22+
/** @var \PHPStan\Symfony\ConsoleApplicationResolver */
23+
private $consoleApplicationResolver;
24+
25+
/** @var \PhpParser\PrettyPrinter\Standard */
26+
private $printer;
27+
28+
public function __construct(ConsoleApplicationResolver $consoleApplicationResolver, Standard $printer)
29+
{
30+
$this->consoleApplicationResolver = $consoleApplicationResolver;
31+
$this->printer = $printer;
32+
}
33+
34+
public function getNodeType(): string
35+
{
36+
return MethodCall::class;
37+
}
38+
39+
/**
40+
* @param \PhpParser\Node $node
41+
* @param \PHPStan\Analyser\Scope $scope
42+
* @return (string|\PHPStan\Rules\RuleError)[] errors
43+
*/
44+
public function processNode(Node $node, Scope $scope): array
45+
{
46+
if (!$node instanceof MethodCall) {
47+
throw new ShouldNotHappenException();
48+
};
49+
50+
$classReflection = $scope->getClassReflection();
51+
if ($classReflection === null) {
52+
throw new ShouldNotHappenException();
53+
}
54+
55+
if (!(new ObjectType('Symfony\Component\Console\Command\Command'))->isSuperTypeOf(new ObjectType($classReflection->getName()))->yes()) {
56+
return [];
57+
}
58+
if (!(new ObjectType('Symfony\Component\Console\Input\InputInterface'))->isSuperTypeOf($scope->getType($node->var))->yes()) {
59+
return [];
60+
}
61+
if (!$node->name instanceof Node\Identifier || $node->name->name !== 'getArgument') {
62+
return [];
63+
}
64+
if (!isset($node->args[0])) {
65+
return [];
66+
}
67+
68+
$argType = $scope->getType($node->args[0]->value);
69+
$argStrings = TypeUtils::getConstantStrings($argType);
70+
if (count($argStrings) !== 1) {
71+
return [];
72+
}
73+
$argName = $argStrings[0]->getValue();
74+
75+
$errors = [];
76+
foreach ($this->consoleApplicationResolver->findCommands($classReflection) as $name => $command) {
77+
try {
78+
$command->getDefinition()->getArgument($argName);
79+
} catch (InvalidArgumentException $e) {
80+
if ($scope->getType(Helper::createMarkerNode($node->var, $argType, $this->printer))->equals($argType)) {
81+
continue;
82+
}
83+
$errors[] = sprintf('Command "%s" does not define argument "%s".', $name, $argName);
84+
}
85+
}
86+
87+
return $errors;
88+
}
89+
90+
}

0 commit comments

Comments
 (0)