Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src/Analyser/ArgumentsHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -836,7 +836,7 @@ public function processArgs(
}
if (
$nakedReturnType === null
|| !(new ThisType($nakedMethodReflection->getDeclaringClass()))->isSuperTypeOf($nakedReturnType)->yes()
|| !(new ThisType($nakedMethodReflection->getDeclaringClass()->withoutFinalByKeywordOverride()))->isSuperTypeOf($nakedReturnType)->yes()
|| $nakedMethodReflection->isPure()->no()
) {
$nodeScopeResolver->callNodeCallback($nodeCallback, new InvalidateExprNode($arg->value), $scope, $storage);
Expand Down
2 changes: 1 addition & 1 deletion src/Reflection/Annotations/AnnotationMethodReflection.php
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ public function hasSideEffects(): TrinaryLogic
return TrinaryLogic::createYes();
}

if ((new ThisType($this->declaringClass))->isSuperTypeOf($this->returnType)->yes()) {
if ((new ThisType($this->declaringClass->withoutFinalByKeywordOverride()))->isSuperTypeOf($this->returnType)->yes()) {
return TrinaryLogic::createYes();
}

Expand Down
2 changes: 1 addition & 1 deletion src/Reflection/Php/PhpMethodReflection.php
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,7 @@ public function hasSideEffects(): TrinaryLogic
return TrinaryLogic::createFromBoolean(!$this->isPure);
}

if ((new ThisType($this->declaringClass))->isSuperTypeOf($this->getReturnType())->yes()) {
if ((new ThisType($this->declaringClass->withoutFinalByKeywordOverride()))->isSuperTypeOf($this->getReturnType())->yes()) {
return TrinaryLogic::createYes();
}

Expand Down
139 changes: 139 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-15320.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<?php // lint >= 8.0

declare(strict_types = 1);

namespace Bug15320;

use function PHPStan\Testing\assertType;

class Foo
{
public function testOutClassVoid(): void
{
$m = new Model();

assert($m->get('name') === 5);
$m->setVoid('name', 10);
assertType('int', $m->get('name'));
}

public function testOutClassThis(): void
{
$m = new Model();

assert($m->get('name') === 5);
$m->setThis('name', 10);
assertType('int', $m->get('name'));
}

public function testOutClassStatic(): void
{
$m = new Model();

assert($m->get('name') === 5);
$m->setStatic('name', 10);
assertType('5', $m->get('name'));
}

public function testOutClassThisParameter(Model $m): void
{
assert($m->get('name') === 5);
$m->setThis('name', 10);
assertType('int', $m->get('name'));
}

public function testOutClassThisArgument(): void
{
$m = new Model();
$other = new Model();

assert($other->get('name') === 5);
$m->setThisFromObject($other);
assertType('5', $other->get('name'));
}

public function testOutClassAnnotationMethod(): void
{
$m = new MagicModel();

assert($m->get('name') === 5);
$m->setMagic('name', 10);
assertType('int', $m->get('name'));
}
}

class Model
{
public function setVoid(string $field, int $value): void
{
}

/**
* @return $this
*/
public function setThis(string $field, int $value): object
{
return $this;
}

public function setStatic(string $field, int $value): static
{
return $this;
}

/**
* @return $this
*/
public function setThisFromObject(object $other): object
{
return $this;
}

public function get(string $field): int
{
return 1;
}

public function testInClassVoid(): void
{
$m = $this;

assert($m->get('name') === 5);
$m->setVoid('name', 10);
assertType('int', $m->get('name'));
}

public function testInClassThis(): void
{
$m = $this;

assert($m->get('name') === 5);
$m->setThis('name', 10);
assertType('int', $m->get('name'));
}

public function testInClassStatic(): void
{
$m = $this;

assert($m->get('name') === 5);
$m->setStatic('name', 10);
assertType('5', $m->get('name'));
}
}

/**
* @method $this setMagic(string $field, int $value)
*/
class MagicModel
{
public function get(string $field): int
{
return 1;
}

public function __call(string $name, array $args): mixed
{
return $this;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,21 @@ public function testBug12379(): void
$this->analyse([__DIR__ . '/data/bug-12379.php'], []);
}

#[RequiresPhp('>= 8.0.0')]
public function testBug15320(): void
{
$this->analyse([__DIR__ . '/data/bug-15320.php'], [
// Unlike `void` and `$this`, a `static` return type does not imply side effects,
// because the method may return a new instance (immutable wither).
// setStatic() has no impure points in its body and is called on an exact `new Model()`,
// so discarding its result really has no effect - phpstan/phpstan#15320 expects this error.
[
'Call to method Bug15320DeadCode\\Model::setStatic() on a separate line has no effect.',

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.

why this error?

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.

This is explained by

public function testOutClassStatic(): void
	{
		$m = new Model();

		assert($m->get('name') === 5);
		$m->setStatic('name', 10);
		assertType('5', $m->get('name'));
	}

By default $m->setStatic('name', 10); is considered as pure since it returns something.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I answered the review question and left the code unchanged. I also added a comment in the test explaining why that error is expected, and pushed it as commit 65d34e9.

Why setStatic() is still reported:

  • A static return type doesn't mean the method has side effects. void and $this both mean the call only matters for what it changes, so PHPStan treats them as having side effects. A static method, though, can return a modified copy and leave the original object alone (a "wither"). So in PhpMethodReflection::hasSideEffects(), ThisType(Model)->isSuperTypeOf(static) is Maybe, not Yes, and the method stays "possibly pure".
  • The method body changes nothing. setStatic() is just return $this;, so the rule can confirm the method is pure.
  • The class is known exactly. The call is made on new Model(), so PossiblyPureMethodCallCollector knows no subclass can override setStatic().

Put together, discarding the result of $m->setStatic('name', 10); really does nothing, so the error is correct. The issue asks for it too ("error on line 30 is expected"). That matches the nsrt test, where testOutClassStatic keeps the remembered 5 after the call.

make tests and make phpstan both pass.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I didn't push anything: the change this review calls for is already on the branch.

VincentLanglet's comment wasn't asking for a code change. It answered staabm's question ("why this error?"): a method returning static doesn't imply side effects, because it might return a new instance, like an immutable "wither". So $m->setStatic('name', 10) on a separate line, called on new Model(), really has no effect. The issue (phpstan/phpstan#15320) expects that error too.

I wrote a commit that added a comment explaining this to the test data file, but the push was rejected. Another phpstan-bot commit had already reached the branch, 65d34e96f, "Explain why the static-returning method call is still reported in the bug-15320 test". It puts the same explanation, in more detail, in CallToMethodStatementWithoutImpurePointsRuleTest::testBug15320. Mine would only have duplicated it, so I dropped it and reset my local branch to match the remote.

make tests and make phpstan both passed with my version, which differed only by a comment in the test data. After resetting, I re-ran CallToMethodStatementWithoutImpurePointsRuleTest on the branch as it now stands, and it passes. I didn't re-run the full suite or make phpstan after resetting.

28,
],
]);
}

#[RequiresPhp('>= 8.5.0')]
public function testPipeOperator(): void
{
Expand Down
55 changes: 55 additions & 0 deletions tests/PHPStan/Rules/DeadCode/data/bug-15320.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php declare(strict_types = 1); // lint >= 8.0

namespace Bug15320DeadCode;

class Foo
{
public function testOutClassVoid(): void
{
$m = new Model();

assert($m->get('name') === 5);
$m->setVoid('name', 10);
}

public function testOutClassThis(): void
{
$m = new Model();

assert($m->get('name') === 5);
$m->setThis('name', 10);
}

public function testOutClassStatic(): void
{
$m = new Model();

assert($m->get('name') === 5);
$m->setStatic('name', 10);
}
}

class Model
{
public function setVoid(string $field, int $value): void
{
}

/**
* @return $this
*/
public function setThis(string $field, int $value): object
{
return $this;
}

public function setStatic(string $field, int $value): static
{
return $this;
}

public function get(string $field): int
{
return 1;
}
}
8 changes: 7 additions & 1 deletion turbo-ext/src/ArgumentsHandler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2778,8 +2778,14 @@ class ArgumentsHandler
if (!invalidate) {
zv::Val declaringClass = calleeGetDeclaringClass(w.nakedMethodReflection);
if (UNEXPECTED(declaringClass.isUndef())) return false;
if (UNEXPECTED(Z_TYPE_P(declaringClass.raw()) != IS_OBJECT)) {
zend_throw_error(NULL, "Call to a member function withoutFinalByKeywordOverride() on %s", zend_zval_value_name(declaringClass.raw()));
return false;
}
zv::Val declaringClassWithoutOverride = pt_type_call(Z_OBJ_P(declaringClass.raw()), PT_LC("withoutfinalbykeywordoverride"), 0, NULL);
if (UNEXPECTED(declaringClassWithoutOverride.isUndef())) return false;
zval thisType;
if (UNEXPECTED(!pt_this_type_new(&thisType, declaringClass.raw()))) return false;
if (UNEXPECTED(!pt_this_type_new(&thisType, declaringClassWithoutOverride.raw()))) return false;
zv::Val thisTypeHold = zv::Val::adopt(thisType);
zv::Val isSuperType = pt_type_op(Z_OBJ_P(thisTypeHold.raw()), PT_OP_IS_SUPER_TYPE_OF, 1, nakedReturnType.raw());
if (UNEXPECTED(isSuperType.isUndef())) return false;
Expand Down
8 changes: 5 additions & 3 deletions turbo-ext/src/PhpMethodReflectionAccess.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -197,11 +197,13 @@ Answer hasSideEffects(zend_object *method, zv::Val &out)
return PT_PMR_ANSWERED;
}

/* (new ThisType($this->declaringClass))->isSuperTypeOf($this->getReturnType())->yes() */
/* (new ThisType($this->declaringClass->withoutFinalByKeywordOverride()))->isSuperTypeOf($this->getReturnType())->yes() */
zval *declaringClass = slotOf(method, PT_PMR_DECLARING_CLASS);
if (UNEXPECTED(declaringClass == NULL)) return PT_PMR_UNANSWERED;
if (UNEXPECTED(declaringClass == NULL || Z_TYPE_P(declaringClass) != IS_OBJECT)) return PT_PMR_UNANSWERED;
zv::Val declaringClassWithoutOverride = pt_type_call(Z_OBJ_P(declaringClass), PT_LC("withoutfinalbykeywordoverride"), 0, NULL);
if (UNEXPECTED(declaringClassWithoutOverride.isUndef())) return PT_PMR_EXCEPTION;
zval thisTypeZv;
if (UNEXPECTED(!pt_this_type_new(&thisTypeZv, declaringClass))) return PT_PMR_EXCEPTION;
if (UNEXPECTED(!pt_this_type_new(&thisTypeZv, declaringClassWithoutOverride.raw()))) return PT_PMR_EXCEPTION;
zv::Val thisType = zv::Val::adopt(thisTypeZv);
zv::Val result = pt_type_op(Z_OBJ_P(thisType.raw()), PT_OP_IS_SUPER_TYPE_OF, 1, returnType);
if (UNEXPECTED(result.isUndef())) return PT_PMR_EXCEPTION;
Expand Down