Skip to content
6 changes: 5 additions & 1 deletion phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -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
122 changes: 120 additions & 2 deletions src/Provider/SymfonyUsageProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -30,7 +31,6 @@
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use ReflectionAttribute;
use ReflectionEnum;
use ReflectionNamedType;
use Reflector;
use ShipMonk\PHPStan\DeadCode\Enum\AccessType;
Expand Down Expand Up @@ -162,6 +162,7 @@ public function getUsages(
$usages = [
...$usages,
...$this->getMethodUsagesFromAttributeReflection($node, $scope),
...$this->getMapInputUsages($node),
];
}

Expand Down Expand Up @@ -644,6 +645,123 @@ private function getMethodUsagesFromAttributeReflection(
return $usages;
}

/**
* @return list<ClassMethodUsage|ClassPropertyUsage>
*/
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<string, true> $visited
* @return list<ClassMethodUsage|ClassPropertyUsage>
*/
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)) {
Expand Down Expand Up @@ -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(
Expand Down
64 changes: 64 additions & 0 deletions tests/Rule/data/providers/symfony.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Loading