Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,13 @@ public function processNode(Node $node, Scope $scope): array

$errors = [];
foreach ($node->get(PossiblyPureNewCollector::class) as $filePath => $data) {
foreach ($data as [$class, $line]) {
foreach ($data as [$class, $line, $hasEmptyConstructorBody]) {
$lowerClass = strtolower($class);
if (!array_key_exists($lowerClass, $classesWithConstructors)) {
if (!$hasEmptyConstructorBody && !array_key_exists($lowerClass, $classesWithConstructors)) {
continue;
}

$originalClassName = $classesWithConstructors[$lowerClass];
$originalClassName = $classesWithConstructors[$lowerClass] ?? $class;
$errors[] = RuleErrorBuilder::message(sprintf(
'Call to new %s() on a separate line has no effect.',
$originalClassName,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,13 @@ public function processNode(Node $node, Scope $scope): array

$errors = [];
foreach ($node->get(PossiblyPureFuncCallCollector::class) as $filePath => $data) {
foreach ($data as [$func, $line]) {
foreach ($data as [$func, $line, $hasEmptyBody]) {
$lowerFunc = strtolower($func);
if (!array_key_exists($lowerFunc, $functions)) {
if (!$hasEmptyBody && !array_key_exists($lowerFunc, $functions)) {
continue;
}

$originalFunctionName = $functions[$lowerFunc];
$originalFunctionName = $functions[$lowerFunc] ?? $func;
$errors[] = RuleErrorBuilder::message(sprintf(
'Call to function %s() on a separate line has no effect.',
$originalFunctionName,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,20 +36,23 @@ public function processNode(Node $node, Scope $scope): array

$errors = [];
foreach ($node->get(PossiblyPureStaticCallCollector::class) as $filePath => $data) {
foreach ($data as [$className, $method, $line]) {
foreach ($data as [$className, $method, $line, $classDisplayName, $hasEmptyBody]) {
$lowerClassName = strtolower($className);
$lowerMethod = strtolower($method);

if (!array_key_exists($lowerClassName, $methods)) {
continue;
}
if (
!array_key_exists($lowerClassName, $methods)
|| !array_key_exists($lowerMethod, $methods[$lowerClassName])
) {
if (!$hasEmptyBody) {
continue;
}

$lowerMethod = strtolower($method);
if (!array_key_exists($lowerMethod, $methods[$lowerClassName])) {
continue;
$originalMethodName = $classDisplayName . '::' . $method;
} else {
$originalMethodName = $methods[$lowerClassName][$lowerMethod];
}

$originalMethodName = $methods[$lowerClassName][$lowerMethod];

$errors[] = RuleErrorBuilder::message(sprintf(
'Call to %s() on a separate line has no effect.',
$originalMethodName,
Expand Down
207 changes: 207 additions & 0 deletions src/Rules/DeadCode/EmptyBodyCallableDetector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\DeadCode;

use PhpParser\Node;
use PhpParser\Node\Stmt\Class_;
use PhpParser\Node\Stmt\ClassMethod;
use PhpParser\Node\Stmt\Declare_;
use PhpParser\Node\Stmt\Function_;
use PhpParser\Node\Stmt\Namespace_;
use PHPStan\DependencyInjection\AutowiredParameter;
use PHPStan\DependencyInjection\AutowiredService;
use PHPStan\Parser\Parser;
use Throwable;
use function array_key_exists;
use function count;
use function is_array;
use function strtolower;

/**
* Determines whether a function or method has an empty body purely from its source file.
*
* An empty body provably has no impure points and no throw points, so it lets the
* "no effect" dead-code rules report calls to functions/methods whose source is not
* part of the analysed file set (e.g. coming from a third-party dependency), where the
* analysis-based *WithoutImpurePointsCollector collectors never run.
*
* Uses a parser that keeps function/method bodies intact - the default analysis parser
* strips bodies of non-analysed files, which would make every non-analysed callable look empty.
*/
#[AutowiredService]
final class EmptyBodyCallableDetector
{

/** @var array<string, Node[]|null> */
private array $parsedFiles = [];

public function __construct(
#[AutowiredParameter(ref: '@currentPhpVersionRichParser')]
private Parser $parser,
)
{
}

public function hasEmptyFunctionBody(?string $fileName, string $functionName): bool
{
$nodes = $this->parseFile($fileName);
if ($nodes === null) {
return false;
}

$functionNode = $this->findFunctionNode($functionName, $nodes);
if ($functionNode === null) {
return false;
}

return $this->hasEmptyBody($functionNode);
}

public function hasEmptyMethodBody(?string $fileName, string $className, string $methodName): bool
{
$nodes = $this->parseFile($fileName);
if ($nodes === null) {
return false;
}

$classNode = $this->findClassNode($className, $nodes);
if ($classNode === null) {
return false;
}

$methodNode = $this->findMethodNode($methodName, $classNode->stmts);
if ($methodNode === null) {
return false;
}

return $this->hasEmptyBody($methodNode);
}

/**
* @return Node[]|null
*/
private function parseFile(?string $fileName): ?array
{
if ($fileName === null) {
return null;
}

if (array_key_exists($fileName, $this->parsedFiles)) {
return $this->parsedFiles[$fileName];
}

try {
return $this->parsedFiles[$fileName] = $this->parser->parseFile($fileName);
} catch (Throwable) {
return $this->parsedFiles[$fileName] = null;
}
}

private function hasEmptyBody(ClassMethod|Function_ $node): bool
{
if ($node->stmts === null || count($node->stmts) !== 0) {
return false;
}

foreach ($node->params as $param) {
// promoted properties assign to $this, by-reference params create new variables
if ($param->flags !== 0 || $param->byRef) {
return false;
}
}

return true;
}

/**
* @param Node[] $nodes
*/
private function findFunctionNode(string $functionName, array $nodes): ?Function_
{
foreach ($nodes as $node) {
if (
$node instanceof Function_
&& $node->namespacedName !== null
&& strtolower($node->namespacedName->toString()) === strtolower($functionName)
) {
return $node;
}
if (
!$node instanceof Namespace_
&& !$node instanceof Declare_
) {
continue;
}
$result = $this->findFunctionNode($functionName, $this->getChildStatements($node));
if ($result !== null) {
return $result;
}
}
return null;
}

/**
* @param Node[] $nodes
*/
private function findClassNode(string $className, array $nodes): ?Class_
{
foreach ($nodes as $node) {
if (
$node instanceof Class_
&& $node->namespacedName !== null
&& $node->namespacedName->toString() === $className
) {
return $node;
}
if (
!$node instanceof Namespace_
&& !$node instanceof Declare_
) {
continue;
}
$result = $this->findClassNode($className, $this->getChildStatements($node));
if ($result !== null) {
return $result;
}
}
return null;
}

/**
* @param Node\Stmt[] $classStatements
*/
private function findMethodNode(string $methodName, array $classStatements): ?ClassMethod
{
foreach ($classStatements as $statement) {
if (
$statement instanceof ClassMethod
&& strtolower($statement->name->toString()) === strtolower($methodName)
) {
return $statement;
}
}
return null;
}

/**
* @return Node[]
*/
private function getChildStatements(Namespace_|Declare_ $node): array
{
$statements = [];
foreach ($node->getSubNodeNames() as $subNodeName) {
$subNode = $node->{$subNodeName};
if (!is_array($subNode)) {
$subNode = [$subNode];
}
foreach ($subNode as $childNode) {
if (!$childNode instanceof Node) {
continue;
}
$statements[] = $childNode;
}
}
return $statements;
}

}
14 changes: 11 additions & 3 deletions src/Rules/DeadCode/PossiblyPureFuncCallCollector.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,19 @@
use PHPStan\Collectors\Collector;
use PHPStan\DependencyInjection\RegisteredCollector;
use PHPStan\Reflection\ReflectionProvider;
use function count;

/**
* @implements Collector<Node\Stmt\Expression, array{string, int}>
* @implements Collector<Node\Stmt\Expression, array{string, int, bool}>
*/
#[RegisteredCollector(level: 4)]
final class PossiblyPureFuncCallCollector implements Collector
{

public function __construct(private ReflectionProvider $reflectionProvider)
public function __construct(
private ReflectionProvider $reflectionProvider,
private EmptyBodyCallableDetector $emptyBodyCallableDetector,
)
{
}

Expand Down Expand Up @@ -61,7 +65,11 @@ public function processNode(Node $node, Scope $scope)
return null;
}

return [$functionReflection->getName(), $node->getStartLine()];
$hasEmptyBody = !$functionReflection->isBuiltin()
&& count($functionReflection->getAsserts()->getAll()) === 0
&& $this->emptyBodyCallableDetector->hasEmptyFunctionBody($functionReflection->getFileName(), $functionReflection->getName());

return [$functionReflection->getName(), $node->getStartLine(), $hasEmptyBody];
}

}
34 changes: 31 additions & 3 deletions src/Rules/DeadCode/PossiblyPureNewCollector.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,22 @@
use PHPStan\Analyser\Scope;
use PHPStan\Collectors\Collector;
use PHPStan\DependencyInjection\RegisteredCollector;
use PHPStan\Reflection\ExtendedMethodReflection;
use PHPStan\Reflection\ReflectionProvider;
use function count;
use function strtolower;

/**
* @implements Collector<Expression, array{string, int}>
* @implements Collector<Expression, array{string, int, bool}>
*/
#[RegisteredCollector(level: 4)]
final class PossiblyPureNewCollector implements Collector
{

public function __construct(private ReflectionProvider $reflectionProvider)
public function __construct(
private ReflectionProvider $reflectionProvider,
private EmptyBodyCallableDetector $emptyBodyCallableDetector,
)
{
}

Expand Down Expand Up @@ -56,7 +61,30 @@ public function processNode(Node $node, Scope $scope)
return null;
}

return [$constructor->getDeclaringClass()->getName(), $node->getStartLine()];
return [
$constructor->getDeclaringClass()->getName(),
$node->getStartLine(),
$this->hasEmptyConstructorBody($constructor),
];
}

private function hasEmptyConstructorBody(ExtendedMethodReflection $constructor): bool
{
if (count($constructor->getAsserts()->getAll()) !== 0) {
return false;
}

$declaringClass = $constructor->getDeclaringClass();
// built-in classes are reflected from stubs with empty bodies that don't reflect reality
if ($declaringClass->isBuiltin()) {
return false;
}

return $this->emptyBodyCallableDetector->hasEmptyMethodBody(
$declaringClass->getFileName(),
$declaringClass->getName(),
$constructor->getName(),
);
}

}
Loading
Loading