Skip to content

Commit e9e5578

Browse files
committed
Support for console InputInterface::getArgument()
1 parent 9c7e936 commit e9e5578

18 files changed

+653
-5
lines changed

composer.json

Lines changed: 3 additions & 2 deletions
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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
parameters:
22
symfony:
33
constant_hassers: true
4+
console_application_loader: null
45

56
rules:
67
- PHPStan\Rules\Symfony\ContainerInterfacePrivateServiceRule
78
- PHPStan\Rules\Symfony\ContainerInterfaceUnknownServiceRule
9+
- PHPStan\Rules\Symfony\UndefinedArgumentRule
10+
- PHPStan\Rules\Symfony\InvalidArgumentDefaultValueRule
811

912
services:
13+
# console resolver
14+
-
15+
factory: PHPStan\Symfony\ConsoleApplicationResolver
16+
arguments: [%symfony.console_application_loader%]
17+
1018
# service map
1119
symfony.serviceMapFactory:
1220
class: PHPStan\Symfony\ServiceMapFactory
@@ -55,3 +63,13 @@ services:
5563
-
5664
factory: PHPStan\Type\Symfony\EnvelopeReturnTypeExtension
5765
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
66+
67+
# InputInterface::getArgument() return type
68+
-
69+
factory: PHPStan\Type\Symfony\InputInterfaceGetArgumentDynamicReturnTypeExtension
70+
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
71+
72+
# InputInterface::hasArgument() type specification
73+
-
74+
factory: PHPStan\Type\Symfony\ArgumentTypeSpecifyingExtension
75+
tags: [phpstan.typeSpecifier.methodTypeSpecifyingExtension]

phpstan.neon

Lines changed: 2 additions & 0 deletions
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
Lines changed: 78 additions & 0 deletions
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+
}
Lines changed: 90 additions & 0 deletions
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+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Symfony;
4+
5+
use PHPStan\Reflection\ClassReflection;
6+
use PHPStan\ShouldNotHappenException;
7+
use PHPStan\Type\ObjectType;
8+
use function file_exists;
9+
use function get_class;
10+
use function is_readable;
11+
12+
final class ConsoleApplicationResolver
13+
{
14+
15+
/** @var \Symfony\Component\Console\Application|null */
16+
private $consoleApplication;
17+
18+
public function __construct(?string $consoleApplicationLoader)
19+
{
20+
if ($consoleApplicationLoader === null) {
21+
return;
22+
}
23+
$this->consoleApplication = $this->loadConsoleApplication($consoleApplicationLoader);
24+
}
25+
26+
/**
27+
* @return \Symfony\Component\Console\Application|null
28+
* @phpcsSuppress SlevomatCodingStandard.TypeHints.TypeHintDeclaration.MissingReturnTypeHint
29+
*/
30+
private function loadConsoleApplication(string $consoleApplicationLoader)
31+
{
32+
if (!file_exists($consoleApplicationLoader)
33+
|| !is_readable($consoleApplicationLoader)
34+
) {
35+
throw new ShouldNotHappenException();
36+
}
37+
38+
return require $consoleApplicationLoader;
39+
}
40+
41+
/**
42+
* @return \Symfony\Component\Console\Command\Command[]
43+
*/
44+
public function findCommands(ClassReflection $classReflection): array
45+
{
46+
if ($this->consoleApplication === null) {
47+
return [];
48+
}
49+
50+
$classType = new ObjectType($classReflection->getName());
51+
if (!(new ObjectType('Symfony\Component\Console\Command\Command'))->isSuperTypeOf($classType)->yes()) {
52+
return [];
53+
}
54+
55+
$commands = [];
56+
foreach ($this->consoleApplication->all() as $name => $command) {
57+
if (!$classType->isSuperTypeOf(new ObjectType(get_class($command)))->yes()) {
58+
continue;
59+
}
60+
$commands[$name] = $command;
61+
}
62+
63+
return $commands;
64+
}
65+
66+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Type\Symfony;
4+
5+
use PhpParser\Node\Expr\MethodCall;
6+
use PhpParser\PrettyPrinter\Standard;
7+
use PHPStan\Analyser\Scope;
8+
use PHPStan\Analyser\SpecifiedTypes;
9+
use PHPStan\Analyser\TypeSpecifier;
10+
use PHPStan\Analyser\TypeSpecifierAwareExtension;
11+
use PHPStan\Analyser\TypeSpecifierContext;
12+
use PHPStan\Reflection\MethodReflection;
13+
use PHPStan\Type\MethodTypeSpecifyingExtension;
14+
15+
final class ArgumentTypeSpecifyingExtension implements MethodTypeSpecifyingExtension, TypeSpecifierAwareExtension
16+
{
17+
18+
/** @var \PhpParser\PrettyPrinter\Standard */
19+
private $printer;
20+
21+
/** @var \PHPStan\Analyser\TypeSpecifier */
22+
private $typeSpecifier;
23+
24+
public function __construct(Standard $printer)
25+
{
26+
$this->printer = $printer;
27+
}
28+
29+
public function getClass(): string
30+
{
31+
return 'Symfony\Component\Console\Input\InputInterface';
32+
}
33+
34+
public function isMethodSupported(MethodReflection $methodReflection, MethodCall $node, TypeSpecifierContext $context): bool
35+
{
36+
return $methodReflection->getName() === 'hasArgument' && !$context->null();
37+
}
38+
39+
public function specifyTypes(MethodReflection $methodReflection, MethodCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes
40+
{
41+
if (!isset($node->args[0])) {
42+
return new SpecifiedTypes();
43+
}
44+
$argType = $scope->getType($node->args[0]->value);
45+
return $this->typeSpecifier->create(
46+
Helper::createMarkerNode($node->var, $argType, $this->printer),
47+
$argType,
48+
$context
49+
);
50+
}
51+
52+
public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void
53+
{
54+
$this->typeSpecifier = $typeSpecifier;
55+
}
56+
57+
}

0 commit comments

Comments
 (0)