Skip to content

Commit c066ed5

Browse files
authored
Preserve property narrowing in match expression results (#6708)
Co-authored-by: Markus Staab <maggus.staab@googlemail.com>
2 parents 62ed200 + 867f37c commit c066ed5

6 files changed

Lines changed: 190 additions & 10 deletions

File tree

‎src/Analyser/ExpressionResult.php‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -543,10 +543,18 @@ private function projectsVoidToNull(bool $nativeTypesPromoted): bool
543543
*/
544544
public function getKeepVoidType(bool $nativeTypesPromoted): Type
545545
{
546+
// Only a call's value read can project void to null. Other expressions
547+
// must honour their narrowed holder even when the raw type is mixed
548+
// (whose isVoid() is maybe, not evidence of an actual void arm).
549+
if (!$this->projectsVoidToNull($nativeTypesPromoted)) {
550+
return $nativeTypesPromoted ? $this->getNativeType() : $this->getType();
551+
}
552+
546553
$rawType = $this->resolveOwnRawType($nativeTypesPromoted);
547-
if (!$rawType->isVoid()->no()) {
548-
// there is void to keep - the raw type is the answer, and no read that
549-
// projects it away may run
554+
$projectedType = $this->projectVoidToNull($rawType, $nativeTypesPromoted);
555+
if ($projectedType !== $rawType && !$projectedType->equals($rawType)) {
556+
// Keep the raw type only when projection removes an actual void arm.
557+
// mixed::isVoid() is maybe, but its projection leaves it unchanged.
550558
return $rawType;
551559
}
552560

‎src/Turbo/TurboExtensionEnabler.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
final class TurboExtensionEnabler
3434
{
3535

36-
public const EXPECTED_EXTENSION_VERSION = '28c29b8';
36+
public const EXPECTED_EXTENSION_VERSION = '16bf720';
3737

3838
private static bool $active = false;
3939

‎tests/PHPStan/Analyser/ExpressionResultTest.php‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use PhpParser\Node\Expr\Exit_;
77
use PhpParser\Node\Expr\FuncCall;
88
use PhpParser\Node\Expr\Variable;
9+
use PhpParser\Node\Name;
910
use PhpParser\Node\Scalar\Int_;
1011
use PhpParser\Node\Stmt;
1112
use PHPStan\Parser\Parser;
@@ -15,7 +16,11 @@
1516
use PHPStan\Type\ArrayType;
1617
use PHPStan\Type\IntegerType;
1718
use PHPStan\Type\MixedType;
19+
use PHPStan\Type\NullType;
20+
use PHPStan\Type\StringType;
1821
use PHPStan\Type\Type;
22+
use PHPStan\Type\TypeCombinator;
23+
use PHPStan\Type\VoidType;
1924
use PHPUnit\Framework\Attributes\DataProvider;
2025
use function count;
2126
use function get_class;
@@ -280,6 +285,36 @@ public function testFinalizePreservesPreliminaryResult(): void
280285
$this->assertSame($type, $final->getType());
281286
}
282287

288+
/** @return iterable<array{Type, Type}> */
289+
public static function dataKeepVoidType(): iterable
290+
{
291+
yield [new VoidType(), new NullType()];
292+
yield [TypeCombinator::union(new IntegerType(), new VoidType()), TypeCombinator::union(new IntegerType(), new NullType())];
293+
yield [TypeCombinator::union(new IntegerType(), new StringType()), TypeCombinator::union(new IntegerType(), new StringType())];
294+
yield [new MixedType(), new MixedType()];
295+
}
296+
297+
#[DataProvider('dataKeepVoidType')]
298+
public function testKeepVoidType(Type $rawType, Type $projectedType): void
299+
{
300+
$scope = self::getContainer()->getByType(ScopeFactory::class)->create(ScopeContext::create('test.php'));
301+
$result = self::getContainer()->getByType(ExpressionResultFactory::class)->create(
302+
$scope,
303+
$scope,
304+
new FuncCall(new Name('gettype')),
305+
false,
306+
false,
307+
[],
308+
[],
309+
static fn (): Type => $rawType,
310+
SpecifiedTypes::emptySpecifyCallback(),
311+
);
312+
313+
$this->assertTrue($rawType->equals($result->getKeepVoidType(false)));
314+
$this->assertTrue($projectedType->equals($result->getType()));
315+
$this->assertTrue($rawType->equals($result->getKeepVoidType(true)));
316+
}
317+
283318
/** @return iterable<array{string, int}> */
284319
public static function dataCopiesPreserveMemoizedAnswers(): iterable
285320
{
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php // lint >= 8.4
2+
3+
namespace Bug15433PropertyHooks;
4+
5+
use function PHPStan\Testing\assertNativeType;
6+
use function PHPStan\Testing\assertType;
7+
8+
interface Value
9+
{
10+
11+
public mixed $value { get; }
12+
13+
}
14+
15+
function normalize(Value $object): void
16+
{
17+
$matched = match (true) {
18+
is_array($object->value) => $object->value,
19+
default => [$object->value],
20+
};
21+
assertType('array<mixed>', $matched);
22+
assertNativeType('array<mixed>', $matched);
23+
24+
$reversed = match (true) {
25+
!is_array($object->value) => [$object->value],
26+
default => $object->value,
27+
};
28+
assertType('array<mixed>', $reversed);
29+
assertNativeType('array<mixed>', $reversed);
30+
31+
if (is_array($object->value)) {
32+
$branched = $object->value;
33+
} else {
34+
$branched = [$object->value];
35+
}
36+
assertType('array<mixed>', $branched);
37+
assertNativeType('array<mixed>', $branched);
38+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<?php // lint >= 8.0
2+
3+
namespace Bug15433;
4+
5+
use function PHPStan\Testing\assertNativeType;
6+
use function PHPStan\Testing\assertType;
7+
8+
class Value
9+
{
10+
11+
public mixed $value;
12+
13+
/** @phpstan-pure */
14+
public function getValue(): mixed
15+
{
16+
return $this->value;
17+
}
18+
19+
/** @phpstan-pure */
20+
public static function getStaticValue(mixed $value): mixed
21+
{
22+
return $value;
23+
}
24+
25+
}
26+
27+
/** @phpstan-pure */
28+
function getValue(mixed $value): mixed
29+
{
30+
return $value;
31+
}
32+
33+
function normalize(Value $object, mixed $value): void
34+
{
35+
$matched = match (true) {
36+
is_array($object->value) => $object->value,
37+
default => [$object->value],
38+
};
39+
assertType('array<mixed>', $matched);
40+
assertNativeType('array<mixed>', $matched);
41+
42+
$reversed = match (true) {
43+
!is_array($object->value) => [$object->value],
44+
default => $object->value,
45+
};
46+
assertType('array<mixed>', $reversed);
47+
assertNativeType('array<mixed>', $reversed);
48+
49+
$integer = match (true) {
50+
is_int($object->value) => $object->value,
51+
default => 0,
52+
};
53+
assertType('int', $integer);
54+
assertNativeType('int', $integer);
55+
56+
$variable = match (true) {
57+
is_array($value) => $value,
58+
default => [$value],
59+
};
60+
assertType('array<mixed>', $variable);
61+
assertNativeType('array<mixed>', $variable);
62+
}
63+
64+
function normalizeCalls(Value $object, mixed $value): void
65+
{
66+
$method = match (true) {
67+
is_array($object->getValue()) => $object->getValue(),
68+
default => [$object->getValue()],
69+
};
70+
assertType('array<mixed>', $method);
71+
assertNativeType('array<mixed>', $method);
72+
73+
$staticMethod = match (true) {
74+
is_array(Value::getStaticValue($value)) => Value::getStaticValue($value),
75+
default => [Value::getStaticValue($value)],
76+
};
77+
assertType('array<mixed>', $staticMethod);
78+
assertNativeType('array<mixed>', $staticMethod);
79+
80+
$function = match (true) {
81+
is_array(getValue($value)) => getValue($value),
82+
default => [getValue($value)],
83+
};
84+
assertType('array<mixed>', $function);
85+
assertNativeType('array<mixed>', $function);
86+
}

‎turbo-ext/src/ExpressionResult.cpp‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -458,14 +458,27 @@ class ExpressionResult
458458
/* Mirrors getKeepVoidType(). */
459459
zv::Val getKeepVoidType(bool nativeTypesPromoted)
460460
{
461+
// Only a call's value read can project void to null. Other expressions
462+
// must honour their narrowed holder even when the raw type is mixed
463+
// (whose isVoid() is maybe, not evidence of an actual void arm).
464+
zend_long projects = projectsVoidToNull(nativeTypesPromoted);
465+
if (UNEXPECTED(projects < 0)) return zv::Val();
466+
if (projects == 0) {
467+
return nativeTypesPromoted ? getNativeType() : getType();
468+
}
469+
461470
zv::Val rawType = resolveOwnRawType(nativeTypesPromoted);
462471
if (UNEXPECTED(rawType.isUndef())) return zv::Val();
463-
zend_long isVoid = trinaryOp(rawType.raw(), PT_OP_IS_VOID);
464-
if (UNEXPECTED(isVoid < 0)) return zv::Val();
465-
if (isVoid != PT_TRI_NO) {
466-
// there is void to keep - the raw type is the answer, and no read that
467-
// projects it away may run
468-
return rawType;
472+
zv::Val projectedType = projectVoidToNull(zv::Val::copyOf(rawType.ref()), nativeTypesPromoted);
473+
if (UNEXPECTED(projectedType.isUndef())) return zv::Val();
474+
if (Z_OBJ_P(projectedType.raw()) != Z_OBJ_P(rawType.raw())) {
475+
zv::Val equal = pt_type_op(Z_OBJ_P(projectedType.raw()), PT_OP_EQUALS, 1, rawType.raw());
476+
if (UNEXPECTED(equal.isUndef())) return zv::Val();
477+
if (!zend_is_true(equal.raw())) {
478+
// Keep the raw type only when projection removes an actual void arm.
479+
// mixed::isVoid() is maybe, but its projection leaves it unchanged.
480+
return rawType;
481+
}
469482
}
470483

471484
// nothing to keep, so this is an ordinary value read: it must honour a

0 commit comments

Comments
 (0)