Skip to content

Commit bb6d5c9

Browse files
authored
Symfony: Add support for #[MapInput] console command DTOs (#340)
1 parent c30bc76 commit bb6d5c9

3 files changed

Lines changed: 189 additions & 3 deletions

File tree

phpstan.neon.dist

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ parameters:
5151
message: "#but it's missing from the PHPDoc @throws tag\\.$#" # allow uncatched exceptions in tests
5252
path: tests/*
5353

54-
# allow referencing any attribute classes
54+
# allow referencing any attribute classes (ReflectionProperty variant only triggers on PHP 8.1,
55+
# where Symfony Console 6.x is installed and MapInput/Argument/Option attribute classes are unknown)
5556
- '#^Parameter \#1 \$name of method PHPStan\\BetterReflection\\Reflection\\Adapter\\ReflectionClass\:\:getAttributes\(\) expects class\-string\|null, string given\.$#'
5657
- '#^Parameter \#1 \$name of method PHPStan\\BetterReflection\\Reflection\\Adapter\\ReflectionMethod\:\:getAttributes\(\) expects class\-string\|null, string given\.$#'
58+
-
59+
message: '#^Parameter \#1 \$name of method PHPStan\\BetterReflection\\Reflection\\Adapter\\ReflectionProperty\:\:getAttributes\(\) expects class\-string\|null, string given\.$#'
60+
reportUnmatched: false

src/Provider/SymfonyUsageProvider.php

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use PhpParser\Node\Stmt\Return_;
1414
use PHPStan\Analyser\Scope;
1515
use PHPStan\BetterReflection\Reflection\Adapter\ReflectionClass;
16+
use PHPStan\BetterReflection\Reflection\Adapter\ReflectionEnum;
1617
use PHPStan\BetterReflection\Reflection\Adapter\ReflectionMethod;
1718
use PHPStan\BetterReflection\Reflection\Adapter\ReflectionProperty;
1819
use PHPStan\BetterReflection\Reflector\Exception\IdentifierNotFound;
@@ -30,7 +31,6 @@
3031
use RecursiveDirectoryIterator;
3132
use RecursiveIteratorIterator;
3233
use ReflectionAttribute;
33-
use ReflectionEnum;
3434
use ReflectionNamedType;
3535
use Reflector;
3636
use ShipMonk\PHPStan\DeadCode\Enum\AccessType;
@@ -162,6 +162,7 @@ public function getUsages(
162162
$usages = [
163163
...$usages,
164164
...$this->getMethodUsagesFromAttributeReflection($node, $scope),
165+
...$this->getMapInputUsages($node),
165166
];
166167
}
167168

@@ -644,6 +645,123 @@ private function getMethodUsagesFromAttributeReflection(
644645
return $usages;
645646
}
646647

648+
/**
649+
* @return list<ClassMethodUsage|ClassPropertyUsage>
650+
*/
651+
private function getMapInputUsages(InClassMethodNode $node): array
652+
{
653+
if ($node->getMethodReflection()->getName() !== '__invoke') {
654+
return [];
655+
}
656+
657+
$nativeReflection = $node->getClassReflection()->getNativeReflection();
658+
659+
$isCommand = $this->hasAttribute($nativeReflection, 'Symfony\Component\Console\Attribute\AsCommand')
660+
|| $nativeReflection->isSubclassOf('Symfony\Component\Console\Command\Command');
661+
662+
if (!$isCommand) {
663+
return [];
664+
}
665+
666+
$usages = [];
667+
668+
foreach ($node->getMethodReflection()->getParameters() as $parameter) {
669+
$isMapInput = false;
670+
671+
foreach ($parameter->getAttributes() as $attributeReflection) {
672+
if ($attributeReflection->getName() === 'Symfony\Component\Console\Attribute\MapInput') {
673+
$isMapInput = true;
674+
break;
675+
}
676+
}
677+
678+
if (!$isMapInput) {
679+
continue;
680+
}
681+
682+
$parameterType = $parameter->getType();
683+
684+
if (!$parameterType->isObject()->yes()) {
685+
continue;
686+
}
687+
688+
foreach ($parameterType->getObjectClassNames() as $dtoClassName) {
689+
$usages = [...$usages, ...$this->collectMapInputDtoUsages($dtoClassName)];
690+
}
691+
}
692+
693+
return $usages;
694+
}
695+
696+
/**
697+
* @param array<string, true> $visited
698+
* @return list<ClassMethodUsage|ClassPropertyUsage>
699+
*/
700+
private function collectMapInputDtoUsages(
701+
string $dtoClassName,
702+
array &$visited = [],
703+
): array
704+
{
705+
if (isset($visited[$dtoClassName])) {
706+
return [];
707+
}
708+
709+
$visited[$dtoClassName] = true;
710+
711+
if (!$this->reflectionProvider->hasClass($dtoClassName)) {
712+
return [];
713+
}
714+
715+
$dtoReflection = $this->reflectionProvider->getClass($dtoClassName);
716+
$nativeReflection = $dtoReflection->getNativeReflection();
717+
$note = 'Console input DTO via #[MapInput]';
718+
$usages = [];
719+
720+
foreach ($nativeReflection->getProperties() as $property) {
721+
if ($property->getDeclaringClass()->getName() !== $dtoClassName) {
722+
continue;
723+
}
724+
725+
$isInputProperty = $this->hasAttribute($property, 'Symfony\Component\Console\Attribute\Argument')
726+
|| $this->hasAttribute($property, 'Symfony\Component\Console\Attribute\Option');
727+
$nestedMapInput = $this->hasAttribute($property, 'Symfony\Component\Console\Attribute\MapInput');
728+
729+
if (!$isInputProperty && !$nestedMapInput) {
730+
continue;
731+
}
732+
733+
$usages[] = $this->createPropertyUsage($property, $note, AccessType::WRITE);
734+
$usages[] = $this->createPropertyUsage($property, $note, AccessType::READ);
735+
736+
if (!$nestedMapInput) {
737+
continue;
738+
}
739+
740+
$propertyType = $property->getType();
741+
742+
if (!$propertyType instanceof ReflectionNamedType || $propertyType->isBuiltin()) {
743+
continue;
744+
}
745+
746+
$usages = [...$usages, ...$this->collectMapInputDtoUsages($propertyType->getName(), $visited)];
747+
}
748+
749+
foreach ($nativeReflection->getMethods() as $dtoMethod) {
750+
if ($dtoMethod->getDeclaringClass()->getName() !== $dtoClassName) {
751+
continue;
752+
}
753+
754+
if ($this->hasAttribute($dtoMethod, 'Symfony\Component\Console\Attribute\Interact')) {
755+
$usages[] = new ClassMethodUsage(
756+
UsageOrigin::createVirtual($this, VirtualUsageData::withNote($note)),
757+
new ClassMethodRef($dtoClassName, $dtoMethod->getName(), possibleDescendant: false),
758+
);
759+
}
760+
}
761+
762+
return $usages;
763+
}
764+
647765
private function shouldMarkAsUsed(ReflectionMethod $method): ?string
648766
{
649767
if ($this->isBundleConstructor($method)) {
@@ -1325,7 +1443,7 @@ private function isProbablySymfonyListener(ReflectionMethod $method): bool
13251443
}
13261444

13271445
/**
1328-
* @param ReflectionClass|ReflectionMethod|ReflectionProperty $classOrMethod
1446+
* @param ReflectionClass|ReflectionMethod|ReflectionProperty|ReflectionEnum $classOrMethod
13291447
* @param ReflectionAttribute::IS_*|0 $flags
13301448
*/
13311449
private function hasAttribute(

tests/Rule/data/providers/symfony.php

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,3 +235,67 @@ class RequiredPropertyService {
235235
public object $unused; // error: Property Symfony\RequiredPropertyService::$unused is never read // error: Property Symfony\RequiredPropertyService::$unused is never written
236236
}
237237

238+
class ImportInput {
239+
#[\Symfony\Component\Console\Attribute\Argument]
240+
public string $file;
241+
242+
#[\Symfony\Component\Console\Attribute\Option]
243+
public bool $force = false;
244+
245+
public string $notAnInput; // error: Property Symfony\ImportInput::$notAnInput is never read // error: Property Symfony\ImportInput::$notAnInput is never written
246+
247+
#[Interact]
248+
public function askForConfirmation(): void {}
249+
}
250+
251+
#[AsCommand(name: 'app:import')]
252+
class ImportCommand extends Command {
253+
public function __invoke(
254+
#[\Symfony\Component\Console\Attribute\MapInput] ImportInput $input,
255+
): int {
256+
echo $input->file;
257+
return 0;
258+
}
259+
}
260+
261+
class OrphanedInput {
262+
#[\Symfony\Component\Console\Attribute\Argument]
263+
public string $name; // error: Property Symfony\OrphanedInput::$name is never read // error: Property Symfony\OrphanedInput::$name is never written
264+
265+
#[Interact]
266+
public function askSomething(): void {} // error: Unused Symfony\OrphanedInput::askSomething
267+
}
268+
269+
class NestedFiltersInput {
270+
#[\Symfony\Component\Console\Attribute\Argument]
271+
public string $tag;
272+
273+
#[\Symfony\Component\Console\Attribute\Option]
274+
public bool $strict = false;
275+
276+
public string $notAnInput; // error: Property Symfony\NestedFiltersInput::$notAnInput is never read // error: Property Symfony\NestedFiltersInput::$notAnInput is never written
277+
278+
#[Interact]
279+
public function askForTag(): void {}
280+
281+
public function deadOnNested(): void {} // error: Unused Symfony\NestedFiltersInput::deadOnNested
282+
}
283+
284+
class WrappedImportInput {
285+
#[\Symfony\Component\Console\Attribute\Argument]
286+
public string $name;
287+
288+
#[\Symfony\Component\Console\Attribute\MapInput]
289+
public NestedFiltersInput $filters;
290+
}
291+
292+
#[AsCommand(name: 'app:import-wrapped')]
293+
class WrappedImportCommand extends Command {
294+
public function __invoke(
295+
#[\Symfony\Component\Console\Attribute\MapInput] WrappedImportInput $input,
296+
): int {
297+
echo $input->name;
298+
echo $input->filters->tag;
299+
return 0;
300+
}
301+
}

0 commit comments

Comments
 (0)