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
29 changes: 24 additions & 5 deletions src/Analyser/ExprHandler/StaticCallHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use PHPStan\Type\TypeWithClassName;
use PHPStan\Type\UnionType;
use ReflectionProperty;
use function array_map;
use function array_merge;
Expand Down Expand Up @@ -193,14 +194,32 @@
$directClassNames = $argValueType->getObjectClassNames();
if (count($directClassNames) > 0) {
$scopeClasses = $directClassNames;
$thisTypes = [];
$scopeTypes = [];
foreach ($directClassNames as $directClassName) {
$thisTypes[] = new ObjectType($directClassName);
$scopeTypes[] = new ObjectType($directClassName);
}
$thisType = TypeCombinator::union(...$thisTypes);
$scopeType = TypeCombinator::union(...$scopeTypes);
} else {
$thisType = $argValueType->getClassStringObjectType();
$scopeClasses = $thisType->getObjectClassNames();
$scopeType = $argValueType->getClassStringObjectType();
$scopeClasses = $scopeType->getObjectClassNames();
}

// $this is the bound object, not an instance of the scope
// class: the scope only refines the members of the bound
// object's type that may be an instance of it (the
// hydrator idiom binding an `object` into the class whose
// members it reads), a member that cannot be one stays
if ($thisType !== null && count($scopeClasses) > 0) {
$thisMembers = $thisType instanceof UnionType ? $thisType->getTypes() : [$thisType];
$refinedThisMembers = [];
foreach ($thisMembers as $thisMember) {
if ($scopeType->isSuperTypeOf($thisMember)->no()) {

Check warning on line 216 in src/Analyser/ExprHandler/StaticCallHandler.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\IsSuperTypeOfCalleeAndArgumentMutator": @@ @@ $thisMembers = $thisType instanceof UnionType ? $thisType->getTypes() : [$thisType]; $refinedThisMembers = []; foreach ($thisMembers as $thisMember) { - if ($scopeType->isSuperTypeOf($thisMember)->no()) { + if ($thisMember->isSuperTypeOf($scopeType)->no()) { $refinedThisMembers[] = $thisMember; continue; }

Check warning on line 216 in src/Analyser/ExprHandler/StaticCallHandler.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\IsSuperTypeOfCalleeAndArgumentMutator": @@ @@ $thisMembers = $thisType instanceof UnionType ? $thisType->getTypes() : [$thisType]; $refinedThisMembers = []; foreach ($thisMembers as $thisMember) { - if ($scopeType->isSuperTypeOf($thisMember)->no()) { + if ($thisMember->isSuperTypeOf($scopeType)->no()) { $refinedThisMembers[] = $thisMember; continue; }
$refinedThisMembers[] = $thisMember;
continue;
}
$refinedThisMembers[] = TypeCombinator::intersect($thisMember, $scopeType);
}
$thisType = TypeCombinator::union(...$refinedThisMembers);
}
}
return $boundScope->enterClosureBind($thisType, $nativeThisType, $scopeClasses);
Expand Down
2 changes: 1 addition & 1 deletion src/Turbo/TurboExtensionEnabler.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
final class TurboExtensionEnabler
{

public const EXPECTED_EXTENSION_VERSION = '6351afb';
public const EXPECTED_EXTENSION_VERSION = '6137ecd';

private static bool $active = false;

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

namespace ClosureBindThisType;

use Closure;
use function PHPStan\Testing\assertNativeType;
use function PHPStan\Testing\assertType;

interface FooInterface
{

}

class Foo
{

private int $secret = 1;

}

class SubFoo extends Foo
{

}

class NoParent
{

}

final class FinalNoParent
{

}

class Test
{

/**
* @param class-string<Foo> $fooClass
*/
public function doFoo(
object $object,
Foo $foo,
SubFoo $subFoo,
NoParent $noParent,
FinalNoParent $finalNoParent,
?Foo $nullableFoo,
Foo|NoParent $fooOrNoParent,
string $fooClass,
$mixed,
): void
{
// no bound object: there is no $this, whatever the scope
Closure::bind(function () {
assertType('*ERROR*', $this);
assertNativeType('*ERROR*', $this);
}, null, Foo::class);

// the scope class refines a bound object that might be an instance of it
Closure::bind(function () {
assertType('ClosureBindThisType\Foo', $this);
assertNativeType('object', $this);
}, $object, Foo::class);

Closure::bind(function () {
assertType('ClosureBindThisType\SubFoo', $this);
assertNativeType('ClosureBindThisType\SubFoo', $this);
}, $subFoo, Foo::class);

Closure::bind(function () {
assertType('ClosureBindThisType\Foo', $this);
assertNativeType('ClosureBindThisType\Foo', $this);
}, $foo, Foo::class);

Closure::bind(function () {
assertType('ClosureBindThisType\FooInterface&ClosureBindThisType\NoParent', $this);
assertNativeType('ClosureBindThisType\NoParent', $this);
}, $noParent, FooInterface::class);

// a bound object that may be an instance of the scope class is assumed to be one
Closure::bind(function () {
assertType('ClosureBindThisType\Foo', $this);
assertNativeType('mixed', $this);
}, $mixed, Foo::class);

Closure::bind(function () {
assertType('ClosureBindThisType\SubFoo', $this);
assertNativeType('ClosureBindThisType\Foo', $this);
}, $foo, SubFoo::class);

// binding $this into another class's scope keeps the enclosing $this
Closure::bind(function () {
assertType('$this(ClosureBindThisType\Test)', $this);
assertNativeType('$this(ClosureBindThisType\Test)', $this);
}, $this, Foo::class);

Closure::bind(function () {
assertType('$this(ClosureBindThisType\Test)&ClosureBindThisType\FooInterface', $this);
assertNativeType('$this(ClosureBindThisType\Test)', $this);
}, $this, FooInterface::class);

// ... but never replaces a bound object that cannot be one
Closure::bind(function () {
assertType('ClosureBindThisType\NoParent', $this);
assertNativeType('ClosureBindThisType\NoParent', $this);
}, $noParent, Foo::class);

Closure::bind(function () {
assertType('ClosureBindThisType\FinalNoParent', $this);
assertNativeType('ClosureBindThisType\FinalNoParent', $this);
}, $finalNoParent, FooInterface::class);

Closure::bind(function () {
assertType('ClosureBindThisType\Foo|ClosureBindThisType\NoParent', $this);
assertNativeType('ClosureBindThisType\Foo|ClosureBindThisType\NoParent', $this);
}, $fooOrNoParent, Foo::class);

Closure::bind(function () {
assertType('ClosureBindThisType\Foo|null', $this);
assertNativeType('ClosureBindThisType\Foo|null', $this);
}, $nullableFoo, Foo::class);

// without a scope the bound object is $this as it is
Closure::bind(function () {
assertType('ClosureBindThisType\NoParent', $this);
assertNativeType('ClosureBindThisType\NoParent', $this);
}, $noParent);

Closure::bind(function () {
assertType('object', $this);
assertNativeType('object', $this);
}, $object);

// the scope given as an object
Closure::bind(function () {
assertType('ClosureBindThisType\Foo', $this);
assertNativeType('object', $this);
}, $object, $foo);

Closure::bind(function () {
assertType('ClosureBindThisType\NoParent', $this);
assertNativeType('ClosureBindThisType\NoParent', $this);
}, $noParent, $foo);

// the scope given as a class-string
Closure::bind(function () {
assertType('ClosureBindThisType\Foo', $this);
assertNativeType('object', $this);
}, $object, $fooClass);

Closure::bind(function () {
assertType('ClosureBindThisType\NoParent', $this);
assertNativeType('ClosureBindThisType\NoParent', $this);
}, $noParent, $fooClass);

// 'static' keeps the current scope, it names no class
Closure::bind(function () {
assertType('object', $this);
assertNativeType('object', $this);
}, $object, 'static');

// arrow functions
Closure::bind(fn () => assertType('ClosureBindThisType\NoParent', $this), $noParent, Foo::class);
Closure::bind(fn () => assertType('ClosureBindThisType\Foo', $this), $object, Foo::class);
Closure::bind(fn () => assertType('*ERROR*', $this), null, Foo::class);

// a static closure has no $this to bind
Closure::bind(static function () {
assertType('*ERROR*', $this);
}, $object, Foo::class);
Closure::bind(static fn () => assertType('*ERROR*', $this), $object, Foo::class);
}

}
17 changes: 17 additions & 0 deletions tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1097,6 +1097,23 @@ public function testBug4865(): void
$this->analyse([__DIR__ . '/data/bug-4865.php'], []);
}

public function testClosureBindThisFromNewThis(): void
{
$this->checkThisOnly = false;
$this->checkNullables = true;
$this->checkUnionTypes = true;
$this->analyse([__DIR__ . '/data/closure-bind-this-from-new-this.php'], [
[
'Call to an undefined method ClosureBindThisFromNewThis\\NoParent::im().',
26,
],
[
'Call to an undefined method ClosureBindThisFromNewThis\\NoParent::im().',
28,
],
]);
}

#[RequiresPhp('>= 8.0.0')]
public function testBug14715(): void
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

namespace ClosureBindThisFromNewThis;

class Foo
{

private function im(): void
{
}

}

class NoParent
{

}

class Test
{

public function doFoo(object $object, NoParent $noParent): void
{
// $this is the bound object, which has no im()
\Closure::bind(function () {
$this->im();
}, $noParent, Foo::class);
\Closure::bind(fn () => $this->im(), new NoParent(), Foo::class);

// the hydrator idiom: an `object` bound into the class whose private members it calls
\Closure::bind(function () {
$this->im();
}, $object, Foo::class);
}

}
14 changes: 14 additions & 0 deletions tests/PHPStan/Rules/Properties/AccessPropertiesRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -941,6 +941,20 @@ public function testBug393(): void
$this->analyse([__DIR__ . '/data/bug-393.php'], []);
}

public function testClosureBindThisFromNewThis(): void
{
$this->checkThisOnly = false;
$this->checkUnionTypes = true;
$this->checkDynamicProperties = false;
$this->analyse([__DIR__ . '/data/closure-bind-this-from-new-this.php'], [
[
'Access to an undefined property ClosureBindThisFromNewThisProperties\\NoParent::$secret.',
30,
'Learn more: <fg=cyan>https://phpstan.org/blog/solving-phpstan-access-to-undefined-property</>',
],
]);
}

public function testObjectShapes(): void
{
$this->checkThisOnly = false;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

namespace ClosureBindThisFromNewThisProperties;

class Foo
{

private int $secret = 1;

}

class NoParent
{

}

class Test
{

public function doFoo(object $object, NoParent $noParent): void
{
// the hydrator idiom: an `object` bound into the class whose private property it reads
$secret = \Closure::bind(function () {
return $this->secret;
}, $object, Foo::class);
$secret = \Closure::bind(fn () => $this->secret, $object, Foo::class);

// $this is the bound object, which has no $secret
$secret = \Closure::bind(function () {
return $this->secret;
}, $noParent, Foo::class);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -788,6 +788,22 @@ public function testBug393(): void
$this->analyse([__DIR__ . '/data/bug-393.php'], []);
}

public function testClosureBindThisFromNewThis(): void
{
$this->cliArgumentsVariablesRegistered = true;
$this->checkMaybeUndefinedVariables = true;
$this->analyse([__DIR__ . '/data/closure-bind-this-from-new-this.php'], [
[
'Undefined variable: $this',
17,
],
[
'Undefined variable: $this',
19,
],
]);
}

public function testBug9023(): void
{
$this->cliArgumentsVariablesRegistered = true;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace ClosureBindThisFromNewThisVariables;

class Foo
{

public function doFoo(): void
{
}

}

function (Foo $foo): void {
// a null $newThis binds no object, whatever the scope
\Closure::bind(function () {
$this->doFoo();
}, null, Foo::class);
\Closure::bind(fn () => $this->doFoo(), null, Foo::class);

\Closure::bind(function () {
$this->doFoo();
}, $foo, Foo::class);
};
Loading
Loading