diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 937c478..8d4d0b9 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -51,6 +51,10 @@ parameters: message: "#but it's missing from the PHPDoc @throws tag\\.$#" # allow uncatched exceptions in tests path: tests/* - # allow referencing any attribute classes + # allow referencing any attribute classes (ReflectionProperty variant only triggers on PHP 8.1, + # where Symfony Console 6.x is installed and MapInput/Argument/Option attribute classes are unknown) - '#^Parameter \#1 \$name of method PHPStan\\BetterReflection\\Reflection\\Adapter\\ReflectionClass\:\:getAttributes\(\) expects class\-string\|null, string given\.$#' - '#^Parameter \#1 \$name of method PHPStan\\BetterReflection\\Reflection\\Adapter\\ReflectionMethod\:\:getAttributes\(\) expects class\-string\|null, string given\.$#' + - + message: '#^Parameter \#1 \$name of method PHPStan\\BetterReflection\\Reflection\\Adapter\\ReflectionProperty\:\:getAttributes\(\) expects class\-string\|null, string given\.$#' + reportUnmatched: false diff --git a/src/Provider/SymfonyUsageProvider.php b/src/Provider/SymfonyUsageProvider.php index 6cb633b..4e40657 100644 --- a/src/Provider/SymfonyUsageProvider.php +++ b/src/Provider/SymfonyUsageProvider.php @@ -13,6 +13,7 @@ use PhpParser\Node\Stmt\Return_; use PHPStan\Analyser\Scope; use PHPStan\BetterReflection\Reflection\Adapter\ReflectionClass; +use PHPStan\BetterReflection\Reflection\Adapter\ReflectionEnum; use PHPStan\BetterReflection\Reflection\Adapter\ReflectionMethod; use PHPStan\BetterReflection\Reflection\Adapter\ReflectionProperty; use PHPStan\BetterReflection\Reflector\Exception\IdentifierNotFound; @@ -30,7 +31,6 @@ use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use ReflectionAttribute; -use ReflectionEnum; use ReflectionNamedType; use Reflector; use ShipMonk\PHPStan\DeadCode\Enum\AccessType; @@ -162,6 +162,7 @@ public function getUsages( $usages = [ ...$usages, ...$this->getMethodUsagesFromAttributeReflection($node, $scope), + ...$this->getMapInputUsages($node), ]; } @@ -644,6 +645,123 @@ private function getMethodUsagesFromAttributeReflection( return $usages; } + /** + * @return list + */ + private function getMapInputUsages(InClassMethodNode $node): array + { + if ($node->getMethodReflection()->getName() !== '__invoke') { + return []; + } + + $nativeReflection = $node->getClassReflection()->getNativeReflection(); + + $isCommand = $this->hasAttribute($nativeReflection, 'Symfony\Component\Console\Attribute\AsCommand') + || $nativeReflection->isSubclassOf('Symfony\Component\Console\Command\Command'); + + if (!$isCommand) { + return []; + } + + $usages = []; + + foreach ($node->getMethodReflection()->getParameters() as $parameter) { + $isMapInput = false; + + foreach ($parameter->getAttributes() as $attributeReflection) { + if ($attributeReflection->getName() === 'Symfony\Component\Console\Attribute\MapInput') { + $isMapInput = true; + break; + } + } + + if (!$isMapInput) { + continue; + } + + $parameterType = $parameter->getType(); + + if (!$parameterType->isObject()->yes()) { + continue; + } + + foreach ($parameterType->getObjectClassNames() as $dtoClassName) { + $usages = [...$usages, ...$this->collectMapInputDtoUsages($dtoClassName)]; + } + } + + return $usages; + } + + /** + * @param array $visited + * @return list + */ + private function collectMapInputDtoUsages( + string $dtoClassName, + array &$visited = [], + ): array + { + if (isset($visited[$dtoClassName])) { + return []; + } + + $visited[$dtoClassName] = true; + + if (!$this->reflectionProvider->hasClass($dtoClassName)) { + return []; + } + + $dtoReflection = $this->reflectionProvider->getClass($dtoClassName); + $nativeReflection = $dtoReflection->getNativeReflection(); + $note = 'Console input DTO via #[MapInput]'; + $usages = []; + + foreach ($nativeReflection->getProperties() as $property) { + if ($property->getDeclaringClass()->getName() !== $dtoClassName) { + continue; + } + + $isInputProperty = $this->hasAttribute($property, 'Symfony\Component\Console\Attribute\Argument') + || $this->hasAttribute($property, 'Symfony\Component\Console\Attribute\Option'); + $nestedMapInput = $this->hasAttribute($property, 'Symfony\Component\Console\Attribute\MapInput'); + + if (!$isInputProperty && !$nestedMapInput) { + continue; + } + + $usages[] = $this->createPropertyUsage($property, $note, AccessType::WRITE); + $usages[] = $this->createPropertyUsage($property, $note, AccessType::READ); + + if (!$nestedMapInput) { + continue; + } + + $propertyType = $property->getType(); + + if (!$propertyType instanceof ReflectionNamedType || $propertyType->isBuiltin()) { + continue; + } + + $usages = [...$usages, ...$this->collectMapInputDtoUsages($propertyType->getName(), $visited)]; + } + + foreach ($nativeReflection->getMethods() as $dtoMethod) { + if ($dtoMethod->getDeclaringClass()->getName() !== $dtoClassName) { + continue; + } + + if ($this->hasAttribute($dtoMethod, 'Symfony\Component\Console\Attribute\Interact')) { + $usages[] = new ClassMethodUsage( + UsageOrigin::createVirtual($this, VirtualUsageData::withNote($note)), + new ClassMethodRef($dtoClassName, $dtoMethod->getName(), possibleDescendant: false), + ); + } + } + + return $usages; + } + private function shouldMarkAsUsed(ReflectionMethod $method): ?string { if ($this->isBundleConstructor($method)) { @@ -1325,7 +1443,7 @@ private function isProbablySymfonyListener(ReflectionMethod $method): bool } /** - * @param ReflectionClass|ReflectionMethod|ReflectionProperty $classOrMethod + * @param ReflectionClass|ReflectionMethod|ReflectionProperty|ReflectionEnum $classOrMethod * @param ReflectionAttribute::IS_*|0 $flags */ private function hasAttribute( diff --git a/tests/Rule/data/providers/symfony.php b/tests/Rule/data/providers/symfony.php index f7fb12b..ee1f6b0 100644 --- a/tests/Rule/data/providers/symfony.php +++ b/tests/Rule/data/providers/symfony.php @@ -235,3 +235,67 @@ class RequiredPropertyService { public object $unused; // error: Property Symfony\RequiredPropertyService::$unused is never read // error: Property Symfony\RequiredPropertyService::$unused is never written } +class ImportInput { + #[\Symfony\Component\Console\Attribute\Argument] + public string $file; + + #[\Symfony\Component\Console\Attribute\Option] + public bool $force = false; + + public string $notAnInput; // error: Property Symfony\ImportInput::$notAnInput is never read // error: Property Symfony\ImportInput::$notAnInput is never written + + #[Interact] + public function askForConfirmation(): void {} +} + +#[AsCommand(name: 'app:import')] +class ImportCommand extends Command { + public function __invoke( + #[\Symfony\Component\Console\Attribute\MapInput] ImportInput $input, + ): int { + echo $input->file; + return 0; + } +} + +class OrphanedInput { + #[\Symfony\Component\Console\Attribute\Argument] + public string $name; // error: Property Symfony\OrphanedInput::$name is never read // error: Property Symfony\OrphanedInput::$name is never written + + #[Interact] + public function askSomething(): void {} // error: Unused Symfony\OrphanedInput::askSomething +} + +class NestedFiltersInput { + #[\Symfony\Component\Console\Attribute\Argument] + public string $tag; + + #[\Symfony\Component\Console\Attribute\Option] + public bool $strict = false; + + public string $notAnInput; // error: Property Symfony\NestedFiltersInput::$notAnInput is never read // error: Property Symfony\NestedFiltersInput::$notAnInput is never written + + #[Interact] + public function askForTag(): void {} + + public function deadOnNested(): void {} // error: Unused Symfony\NestedFiltersInput::deadOnNested +} + +class WrappedImportInput { + #[\Symfony\Component\Console\Attribute\Argument] + public string $name; + + #[\Symfony\Component\Console\Attribute\MapInput] + public NestedFiltersInput $filters; +} + +#[AsCommand(name: 'app:import-wrapped')] +class WrappedImportCommand extends Command { + public function __invoke( + #[\Symfony\Component\Console\Attribute\MapInput] WrappedImportInput $input, + ): int { + echo $input->name; + echo $input->filters->tag; + return 0; + } +}