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
14 changes: 11 additions & 3 deletions src/Analyser/ExpressionResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -543,10 +543,18 @@ private function projectsVoidToNull(bool $nativeTypesPromoted): bool
*/
public function getKeepVoidType(bool $nativeTypesPromoted): Type
Comment thread
staabm marked this conversation as resolved.
Resolved
{
// Only a call's value read can project void to null. Other expressions
// must honour their narrowed holder even when the raw type is mixed
// (whose isVoid() is maybe, not evidence of an actual void arm).
if (!$this->projectsVoidToNull($nativeTypesPromoted)) {

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.

does this if-block still serve a purpose?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It is no longer needed for correctness after the projection comparison below, but it keeps the non-projecting fast path. With a tracked holder, getType()/getNativeType() can return the narrowed type without resolving the raw callback; I checked both flavours and removing this guard changes that from zero callback calls to one, with the same final type.

return $nativeTypesPromoted ? $this->getNativeType() : $this->getType();
Comment thread
staabm marked this conversation as resolved.
Resolved
}

$rawType = $this->resolveOwnRawType($nativeTypesPromoted);
if (!$rawType->isVoid()->no()) {
// there is void to keep - the raw type is the answer, and no read that
// projects it away may run
$projectedType = $this->projectVoidToNull($rawType, $nativeTypesPromoted);
if ($projectedType !== $rawType && !$projectedType->equals($rawType)) {
// Keep the raw type only when projection removes an actual void arm.
// mixed::isVoid() is maybe, but its projection leaves it unchanged.
return $rawType;
}

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 = '28c29b8';
public const EXPECTED_EXTENSION_VERSION = '16bf720';

private static bool $active = false;

Expand Down
35 changes: 35 additions & 0 deletions tests/PHPStan/Analyser/ExpressionResultTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use PhpParser\Node\Expr\Exit_;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Expr\Variable;
use PhpParser\Node\Name;
use PhpParser\Node\Scalar\Int_;
use PhpParser\Node\Stmt;
use PHPStan\Parser\Parser;
Expand All @@ -15,7 +16,11 @@
use PHPStan\Type\ArrayType;
use PHPStan\Type\IntegerType;
use PHPStan\Type\MixedType;
use PHPStan\Type\NullType;
use PHPStan\Type\StringType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use PHPStan\Type\VoidType;
use PHPUnit\Framework\Attributes\DataProvider;
use function count;
use function get_class;
Expand Down Expand Up @@ -280,6 +285,36 @@ public function testFinalizePreservesPreliminaryResult(): void
$this->assertSame($type, $final->getType());
}

/** @return iterable<array{Type, Type}> */
public static function dataKeepVoidType(): iterable
{
yield [new VoidType(), new NullType()];
yield [TypeCombinator::union(new IntegerType(), new VoidType()), TypeCombinator::union(new IntegerType(), new NullType())];
yield [TypeCombinator::union(new IntegerType(), new StringType()), TypeCombinator::union(new IntegerType(), new StringType())];
yield [new MixedType(), new MixedType()];
}

#[DataProvider('dataKeepVoidType')]
public function testKeepVoidType(Type $rawType, Type $projectedType): void
{
$scope = self::getContainer()->getByType(ScopeFactory::class)->create(ScopeContext::create('test.php'));
$result = self::getContainer()->getByType(ExpressionResultFactory::class)->create(
$scope,
$scope,
new FuncCall(new Name('gettype')),
false,
false,
[],
[],
static fn (): Type => $rawType,
SpecifiedTypes::emptySpecifyCallback(),
);

$this->assertTrue($rawType->equals($result->getKeepVoidType(false)));
$this->assertTrue($projectedType->equals($result->getType()));
$this->assertTrue($rawType->equals($result->getKeepVoidType(true)));
}

/** @return iterable<array{string, int}> */
public static function dataCopiesPreserveMemoizedAnswers(): iterable
{
Expand Down
38 changes: 38 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-15433-property-hooks.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php // lint >= 8.4

namespace Bug15433PropertyHooks;

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

interface Value
{

public mixed $value { get; }

}

function normalize(Value $object): void
{
$matched = match (true) {
is_array($object->value) => $object->value,
default => [$object->value],
};
assertType('array<mixed>', $matched);
assertNativeType('array<mixed>', $matched);

$reversed = match (true) {
!is_array($object->value) => [$object->value],
default => $object->value,
};
assertType('array<mixed>', $reversed);
assertNativeType('array<mixed>', $reversed);

if (is_array($object->value)) {
$branched = $object->value;
} else {
$branched = [$object->value];
}
assertType('array<mixed>', $branched);
assertNativeType('array<mixed>', $branched);
}
86 changes: 86 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-15433.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php // lint >= 8.0

namespace Bug15433;

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

class Value
{

public mixed $value;

/** @phpstan-pure */
public function getValue(): mixed
{
return $this->value;
}

/** @phpstan-pure */
public static function getStaticValue(mixed $value): mixed
{
return $value;
}

}

/** @phpstan-pure */
function getValue(mixed $value): mixed
{
return $value;
}

function normalize(Value $object, mixed $value): void
{
$matched = match (true) {
is_array($object->value) => $object->value,
default => [$object->value],
};
assertType('array<mixed>', $matched);
assertNativeType('array<mixed>', $matched);

$reversed = match (true) {
!is_array($object->value) => [$object->value],
default => $object->value,
};
assertType('array<mixed>', $reversed);
assertNativeType('array<mixed>', $reversed);

$integer = match (true) {
is_int($object->value) => $object->value,
default => 0,
};
assertType('int', $integer);
assertNativeType('int', $integer);

$variable = match (true) {
is_array($value) => $value,
default => [$value],
};
assertType('array<mixed>', $variable);
assertNativeType('array<mixed>', $variable);
}

function normalizeCalls(Value $object, mixed $value): void
{
$method = match (true) {
is_array($object->getValue()) => $object->getValue(),
default => [$object->getValue()],
};
assertType('array<mixed>', $method);
assertNativeType('array<mixed>', $method);

$staticMethod = match (true) {
is_array(Value::getStaticValue($value)) => Value::getStaticValue($value),
default => [Value::getStaticValue($value)],
};
assertType('array<mixed>', $staticMethod);
assertNativeType('array<mixed>', $staticMethod);

$function = match (true) {
is_array(getValue($value)) => getValue($value),
default => [getValue($value)],
};
assertType('array<mixed>', $function);
assertNativeType('array<mixed>', $function);
}
25 changes: 19 additions & 6 deletions turbo-ext/src/ExpressionResult.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -458,14 +458,27 @@ class ExpressionResult
/* Mirrors getKeepVoidType(). */
zv::Val getKeepVoidType(bool nativeTypesPromoted)
{
// Only a call's value read can project void to null. Other expressions
// must honour their narrowed holder even when the raw type is mixed
// (whose isVoid() is maybe, not evidence of an actual void arm).
zend_long projects = projectsVoidToNull(nativeTypesPromoted);
if (UNEXPECTED(projects < 0)) return zv::Val();
if (projects == 0) {
return nativeTypesPromoted ? getNativeType() : getType();
Comment on lines +464 to +467
}

zv::Val rawType = resolveOwnRawType(nativeTypesPromoted);
if (UNEXPECTED(rawType.isUndef())) return zv::Val();
zend_long isVoid = trinaryOp(rawType.raw(), PT_OP_IS_VOID);
if (UNEXPECTED(isVoid < 0)) return zv::Val();
if (isVoid != PT_TRI_NO) {
// there is void to keep - the raw type is the answer, and no read that
// projects it away may run
return rawType;
zv::Val projectedType = projectVoidToNull(zv::Val::copyOf(rawType.ref()), nativeTypesPromoted);
if (UNEXPECTED(projectedType.isUndef())) return zv::Val();
if (Z_OBJ_P(projectedType.raw()) != Z_OBJ_P(rawType.raw())) {
zv::Val equal = pt_type_op(Z_OBJ_P(projectedType.raw()), PT_OP_EQUALS, 1, rawType.raw());
if (UNEXPECTED(equal.isUndef())) return zv::Val();
if (!zend_is_true(equal.raw())) {
// Keep the raw type only when projection removes an actual void arm.
// mixed::isVoid() is maybe, but its projection leaves it unchanged.
return rawType;
}
}

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