shipmonk-rnd / dead-code-detector

💀 PHP unused code detection via PHPStan extension. Detects dead cycles, supports libs like Symfony, Doctrine, PHPUnit etc. Can automatically remove dead PHP code.
100 stars 6 forks source link

Unused interface, abstract class and trait detection #71

Open vincentchalamon opened 1 month ago

vincentchalamon commented 1 month ago

I recently worked on a rule to detect unused interfaces, abstract classes and traits from the source code, highly inspired from your plugin (using collectors).

In case you might be interested, here is a shot:

namespace App\Utils\PHPStan\Rules;

use App\Utils\PHPStan\Collector\DeadCode\ClassCollector;
use App\Utils\PHPStan\Collector\DeadCode\PossiblyUnusedClassCollector;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Node\CollectedDataNode;
use PHPStan\Rules\IdentifierRuleError;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;

/**
 * Checks unimplemented classes.
 */
final class DeadClassRule implements Rule
{
    /**
     * @var array<string, IdentifierRuleError>
     */
    private array $errors = [];

    #[\Override]
    public function getNodeType(): string
    {
        return CollectedDataNode::class;
    }

    /**
     * @param CollectedDataNode $node
     */
    #[\Override]
    public function processNode(Node $node, Scope $scope): array
    {
        if ($node->isOnlyFilesAnalysis()) {
            return [];
        }

        $classDeclarationData = $node->get(ClassCollector::class);
        $possiblyUnusedClasses = array_flip(array_map(static fn (array $values): string => $values[0], $node->get(PossiblyUnusedClassCollector::class)));

        foreach ($classDeclarationData as $classesInFile) {
            foreach ($classesInFile as $classPairs) {
                foreach ($classPairs as $ancestor => $descendant) {
                    if (\array_key_exists($ancestor, $possiblyUnusedClasses)) {
                        // ancestor is used, remove it from collection
                        unset($possiblyUnusedClasses[$ancestor]);
                    }
                }
            }
        }

        foreach ($possiblyUnusedClasses as $className => $file) {
            $this->errors[$className] = RuleErrorBuilder::message("Unused {$className}")
                ->file($file)
                ->line((new \ReflectionClass($className))->getStartLine())
                ->identifier('shipmonk.deadMethod')
                ->build();
        }

        return array_values($this->errors);
    }
}

#########################################

namespace App\Utils\PHPStan\Collector\DeadCode;

use PhpParser\Node;
use PhpParser\Node\Stmt\Class_;
use PhpParser\Node\Stmt\ClassLike;
use PhpParser\Node\Stmt\Enum_;
use PHPStan\Analyser\Scope;
use PHPStan\Collectors\Collector;

/**
 * @implements Collector<ClassLike, string>
 */
class PossiblyUnusedClassCollector implements Collector
{
    #[\Override]
    public function getNodeType(): string
    {
        return ClassLike::class;
    }

    /**
     * @param ClassLike $node
     */
    #[\Override]
    public function processNode(Node $node, Scope $scope): ?string
    {
        // can't determine if a class is unused due to framework (except for abstract classes)
        if ($node instanceof Class_ && !$node->isAbstract()) {
            return null;
        }

        // can't determine if an enum is unused
        if ($node instanceof Enum_) {
            return null;
        }

        // node should be an interface, a trait or an abstract class
        return $node->namespacedName?->toString();
    }
}

#########################################

namespace App\Utils\PHPStan\Collector\DeadCode;

use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Collectors\Collector;
use PHPStan\Node\InClassNode;

/**
 * @implements Collector<InClassNode, <string, string>>
 */
class ClassCollector implements Collector
{
    #[\Override]
    public function getNodeType(): string
    {
        return InClassNode::class;
    }

    /**
     * @param InClassNode $node
     *
     * @return array<string, string>
     */
    #[\Override]
    public function processNode(Node $node, Scope $scope): array
    {
        $pairs = [];
        $origin = $node->getClassReflection();

        foreach ($origin->getAncestors() as $ancestor) {
            // ignore self
            if ($ancestor === $origin) {
                continue;
            }

            // ignore ancestors from PHP global namespace
            if ($ancestor->isInternal()) {
                continue;
            }

            // ignore ancestors from vendor
            if (str_contains((string) $ancestor->getFileName(), '/vendor/')) {
                continue;
            }

            $pairs[$ancestor->getName()] = $ancestor->getFileName();
        }

        return $pairs;
    }
}

I'm also wondering if I missed a use-case which could lead to an error or a false-alert. Any opinion about it?

Of course, if you're interested about it, I would be happy to contribute or help.

janedbal commented 1 month ago

I have a big list of features to implement in future, and "dead class analysis" is one of them. You just mentioned the easiest part of it, but the problem is more complex in general.

Unused traits are already implemented in native PHPStan. The rest will be much easier once I finalize internal structures refactoring.

Maybe I'll consider adding this easy part after my refactoring is done. Thanks.

janedbal commented 1 month ago

I just realized we cannot reliably tell that some interface is unused just by checking that it is never implemented / extended in another interface because it can contain used constant. So until we implement dead constants analysis, we cannot implement this one.