Skip to content
Open
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
31 changes: 30 additions & 1 deletion src/Node/ClassStatementsGatherer.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Expr\StaticPropertyFetch;
use PhpParser\Node\Identifier;
use PhpParser\Node\Name;
use PHPStan\Analyser\Scope;
use PHPStan\Node\Constant\ClassConstantFetch;
use PHPStan\Node\Expr\SetExistingOffsetValueTypeExpr;
Expand Down Expand Up @@ -200,7 +201,35 @@ private function gatherNodes(Node $node, Scope $scope): void
return;
}
if ($node instanceof Array_ && count($node->items) === 2) {
$this->methodCalls[] = new \PHPStan\Node\Method\MethodCall($node, $scope);
$firstItem = $node->items[0];
if (!$firstItem->unpack) {
$firstExpr = $firstItem->value;
if ($firstExpr instanceof Expr\Variable && $firstExpr->name === 'this') {
$this->methodCalls[] = new \PHPStan\Node\Method\MethodCall($node, $scope);
return;
}

if (
$firstExpr instanceof Expr\ClassConstFetch
&& $firstExpr->name instanceof Identifier
&& $firstExpr->name->toLowerString() === 'class'
) {
if ($firstExpr->class instanceof Name) {
$className = $firstExpr->class->toLowerString();
if (
$className === 'self'
|| $className === 'static'
|| $className === strtolower($this->classReflection->getName())
) {
$this->methodCalls[] = new \PHPStan\Node\Method\MethodCall($node, $scope);
return;
}
} elseif ($firstExpr->class instanceof Expr\Variable && $firstExpr->class->name === 'this') {
$this->methodCalls[] = new \PHPStan\Node\Method\MethodCall($node, $scope);
return;
}
}
}
return;
}
if ($node instanceof Expr\ClassConstFetch) {
Expand Down
80 changes: 80 additions & 0 deletions tests/PHPStan/Node/ClassStatementsGathererTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php declare(strict_types = 1);

namespace PHPStan\Node;

use PhpParser\Node;
use PhpParser\Node\Expr\Array_;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\IdentifierRuleError;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Testing\RuleTestCase;
use function sprintf;

/**
* @extends RuleTestCase<Rule<ClassMethodsNode>>
*/
class ClassStatementsGathererTest extends RuleTestCase
{

/**
* @return Rule<ClassMethodsNode>
*/
protected function getRule(): Rule
{
return new /** @implements Rule<ClassMethodsNode> */ class implements Rule {

public function getNodeType(): string
{
return ClassMethodsNode::class;
}

/**
* @param ClassMethodsNode $node
* @return list<IdentifierRuleError>
*/
public function processNode(Node $node, Scope $scope): array
{
$errors = [];
foreach ($node->getMethodCalls() as $methodCall) {
$callNode = $methodCall->getNode();
if (!$callNode instanceof Array_) {
continue;
}

$errors[] = RuleErrorBuilder::message(
sprintf('Gathered callable array on line %d.', $callNode->getStartLine()),
)->line($callNode->getStartLine())
->identifier('tests.gatheredCallableArray')
->build();
}

return $errors;
}

};
}

public function testGatherCallableArrays(): void
{
$this->analyse([__DIR__ . '/data/class-statements-gatherer-callable-array.php'], [
[
'Gathered callable array on line 10.',
10,
],
[
'Gathered callable array on line 11.',
11,
],
[
'Gathered callable array on line 12.',
12,
],
[
'Gathered callable array on line 13.',
13,
],
]);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

namespace ClassStatementsGathererCallableArray;

class Foo
{

public function test(): void
{
$a = [$this, 'method'];

@staabm staabm Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doesn't work for

$a = [$this, rand(0, 1) ? 'method' : 'someOther'];

or

$m = rand(0, 1) ? 'method' : 'someOther';
$a = [$this, $m];

$b = [self::class, 'method'];
$c = [static::class, 'method'];
$d = [Foo::class, 'method'];

$x1 = ['SomeClass', 'method'];
$x2 = [\stdClass::class, 'method'];
$x3 = ['foo', 'bar'];
$x4 = [1, 2];
[, $x5] = ['foo', 'bar'];
}

}
10 changes: 10 additions & 0 deletions tests/PHPStan/Rules/DeadCode/UnusedPrivateMethodRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -149,4 +149,14 @@ public function testBug14880(): void
$this->analyse([__DIR__ . '/data/bug-14880.php'], []);
}

public function testCallableArrays(): void
{
$this->analyse([__DIR__ . '/data/unused-private-method-callable-arrays.php'], [
[
'Static method UnusedPrivateMethodCallableArrays\Foo::statusKey() is unused.',
29,
],
]);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

namespace UnusedPrivateMethodCallableArrays;

class BigContainer
{

public static function statusKey(): void
{
}

}

class Foo
{

private static function doSelf(): void
{
}

private static function doStatic(): void
{
}

private static function doClassName(): void
{
}

private static function statusKey(): void
{
}

public function run(): void
{
$a = [self::class, 'doSelf'];
$b = [static::class, 'doStatic'];
$c = [Foo::class, 'doClassName'];

$data = [BigContainer::class, 'statusKey'];
$data2 = ['UnusedPrivateMethodCallableArrays\BigContainer', 'statusKey'];
}

}
67 changes: 66 additions & 1 deletion turbo-ext/src/ClassStatementsGatherer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ static NodeProp pt_csg_class_method_stmt_name = PT_NODE_PROP(PT_CLASS_CLASS_METH
static NodeProp pt_csg_class_method_stmt_params = PT_NODE_PROP(PT_CLASS_CLASS_METHOD_STMT, "params");
static NodeProp pt_csg_func_call_name = PT_NODE_PROP(PT_CLASS_FUNC_CALL, "name");
static NodeProp pt_csg_array_items = PT_NODE_PROP(PT_CLASS_ARRAY_EXPR, "items");
static NodeProp pt_csg_array_item_value = PT_NODE_PROP(PT_CLASS_ARRAY_ITEM, "value");
static NodeProp pt_csg_array_item_unpack = PT_NODE_PROP(PT_CLASS_ARRAY_ITEM, "unpack");
static NodeProp pt_csg_class_const_fetch_class = PT_NODE_PROP(PT_CLASS_CLASS_CONST_FETCH, "class");
static NodeProp pt_csg_class_const_fetch_name = PT_NODE_PROP(PT_CLASS_CLASS_CONST_FETCH, "name");
static NodeProp pt_csg_property_assign_fetch = PT_NODE_PROP(PT_CLASS_PROPERTY_ASSIGN_NODE, "propertyFetch");
static NodeProp pt_csg_property_assign_expr = PT_NODE_PROP(PT_CLASS_PROPERTY_ASSIGN_NODE, "assignedExpr");
static NodeProp pt_csg_coalesce_var = PT_NODE_PROP(PT_CLASS_COALESCE_ASSIGN_OP_EXPR, "var");
Expand Down Expand Up @@ -235,6 +239,64 @@ class ClassStatementsGatherer
return pt_type_call_static_ce(typeUtils, PT_LC("findthistype"), 1, type);
}

static bool isCallableArrayCandidate(HashTable *items, zval *ownName)
{
zval *firstItem = zend_hash_index_find(items, 0);
if (firstItem == NULL || Z_TYPE_P(firstItem) != IS_OBJECT || !isInstanceOf(Z_OBJ_P(firstItem), PT_CLASS_ARRAY_ITEM)) {
return false;
}

zval *unpack = pt_csg_array_item_unpack.of(Z_OBJ_P(firstItem));
if (unpack != NULL && zend_is_true(unpack)) {
return false;
}

zval *itemValue = pt_csg_array_item_value.of(Z_OBJ_P(firstItem));
if (itemValue == NULL || Z_TYPE_P(itemValue) != IS_OBJECT) {
return false;
}

zend_object *valObj = Z_OBJ_P(itemValue);
if (isInstanceOf(valObj, PT_CLASS_VARIABLE)) {
zval *varName = pt_csg_variable_name.of(valObj);
return varName != NULL && Z_TYPE_P(varName) == IS_STRING && zend_string_equals_literal(Z_STR_P(varName), "this");
}

if (isInstanceOf(valObj, PT_CLASS_CLASS_CONST_FETCH)) {
zend_object *constName = pt_csg_class_const_fetch_name.objectOf(valObj, PT_CLASS_IDENTIFIER);
zend_string *constNameStr = constName != NULL ? visitors::nameString(constName, pt_csg_identifier_name) : NULL;
if (constNameStr == NULL || !visitors::lowerEquals(constNameStr, "class")) {
return false;
}

zend_object *classNode = pt_csg_class_const_fetch_class.objectOf(valObj, PT_CLASS_NAME);
if (classNode != NULL) {
zend_string *classNameStr = visitors::nameString(classNode, pt_csg_name_name);
if (classNameStr == NULL) {
return false;
}

if (visitors::lowerEquals(classNameStr, "self") || visitors::lowerEquals(classNameStr, "static")) {
return true;
}

if (Z_TYPE_P(ownName) == IS_STRING && zend_string_equals_ci(classNameStr, Z_STR_P(ownName))) {
return true;
}

return false;
}

zval *classExpr = pt_csg_class_const_fetch_class.of(valObj);
if (classExpr != NULL && Z_TYPE_P(classExpr) == IS_OBJECT && isInstanceOf(Z_OBJ_P(classExpr), PT_CLASS_VARIABLE)) {
zval *varName = pt_csg_variable_name.of(Z_OBJ_P(classExpr));
return varName != NULL && Z_TYPE_P(varName) == IS_STRING && zend_string_equals_literal(Z_STR_P(varName), "this");
}
}

return false;
}

bool gatherNodes(zend_object *node, zval *scope)
{
bool inClass;
Expand Down Expand Up @@ -304,7 +366,10 @@ class ClassStatementsGatherer
if (isInstanceOf(node, PT_CLASS_ARRAY_EXPR)) {
zval *items = pt_csg_array_items.of(node);
if (items != NULL && Z_TYPE_P(items) == IS_ARRAY && zend_hash_num_elements(Z_ARRVAL_P(items)) == 2) {
return pushNodeScope(slots::methodCalls, PT_CLASS_GATHERED_METHOD_CALL, &nodeZv, scope);
if (isCallableArrayCandidate(Z_ARRVAL_P(items), ownName.raw())) {
return pushNodeScope(slots::methodCalls, PT_CLASS_GATHERED_METHOD_CALL, &nodeZv, scope);
}
return true;
}
}
if (isInstanceOf(node, PT_CLASS_CLASS_CONST_FETCH)) return pushNodeScope(slots::constantFetches, PT_CLASS_CLASS_CONSTANT_FETCH, &nodeZv, scope);
Expand Down
Loading