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
24 changes: 23 additions & 1 deletion src/Type/Generic/GenericObjectType.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use PHPStan\Reflection\Type\UnresolvedMethodPrototypeReflection;
use PHPStan\Reflection\Type\UnresolvedPropertyPrototypeReflection;
use PHPStan\ShouldNotHappenException;
use PHPStan\TrinaryLogic;
use PHPStan\Type\AcceptsResult;
use PHPStan\Type\CompoundType;
use PHPStan\Type\ErrorType;
Expand Down Expand Up @@ -202,7 +203,28 @@ private function isSuperTypeOfInternal(Type $type, bool $acceptsContext): IsSupe
if (!$thisVariance->invariant()) {
$results[] = $thisVariance->isValidVariance($templateType, $this->types[$i], $ancestor->types[$i], $strictVariance);
} else {
$results[] = $templateType->isValidVariance($this->types[$i], $ancestor->types[$i], $strictVariance);
$varianceResult = $templateType->isValidVariance($this->types[$i], $ancestor->types[$i], $strictVariance);

// Invariance means `Foo<Cat>` is not assignable to `Foo<Animal>`, it does not
// mean the two are disjoint - the same object can satisfy both claims. Saying
// "no" outside of the accepts context would let TypeCombinator::intersect()
// and dead code detection conclude that narrowing `Foo<Animal>` down to
// `Foo<Cat>` is impossible. Assignability is still answered with "no", so
// variance keeps being enforced on arguments and return types.
if (
!$acceptsContext
&& $templateType->getVariance()->invariant()
&& $varianceResult->no()
&& !$this->types[$i]->isSuperTypeOf($ancestor->types[$i])->no()
) {
$varianceResult = new IsSuperTypeOfResult(
TrinaryLogic::createMaybe(),
$varianceResult->reasons,
$varianceResult->lazyReasons,
);
}

$results[] = $varianceResult;
}

$results[] = IsSuperTypeOfResult::createFromBoolean($thisVariance->validPosition($ancestorVariance));
Expand Down
69 changes: 69 additions & 0 deletions src/Type/TypeCombinator.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,13 @@
use PHPStan\Type\Constant\ConstantIntegerType;
use PHPStan\Type\Constant\ConstantStringType;
use PHPStan\Type\Generic\GenericClassStringType;
use PHPStan\Type\Generic\GenericObjectType;
use PHPStan\Type\Generic\TemplateArrayType;
use PHPStan\Type\Generic\TemplateBenevolentUnionType;
use PHPStan\Type\Generic\TemplateMixedType;
use PHPStan\Type\Generic\TemplateType;
use PHPStan\Type\Generic\TemplateTypeFactory;
use PHPStan\Type\Generic\TemplateTypeVariance;
use PHPStan\Type\Generic\TemplateUnionType;
use function array_fill;
use function array_filter;
Expand Down Expand Up @@ -2076,6 +2078,18 @@ public static function doIntersect(Type ...$types): Type
continue;
}

$mergedGenericObject = self::mergeGenericObjectTypes($types[$i], $types[$j]);
if ($mergedGenericObject !== null) {
if ($mergedGenericObject instanceof NeverType) {
return $mergedGenericObject;
}

$types[$i] = $mergedGenericObject;
array_splice($types, $j--, 1);
$typesCount--;
continue;
}

if (
$types[$i] instanceof ArrayType
&& get_class($types[$i]) === ArrayType::class
Expand Down Expand Up @@ -2139,6 +2153,61 @@ public static function doIntersect(Type ...$types): Type
return new IntersectionType($types);
}

/**
* Invariance of a template type means `Foo<Cat>` is not assignable to `Foo<Animal>`.
* It does not mean the two are disjoint - PHP generics are erased, so the same object
* can satisfy both claims. isSuperTypeOf() answers "no" in both directions there, which
* would otherwise make intersect() collapse `Foo<Animal> & Foo<Cat>` into never.
*
* Returns null when the two types are not the same parameterized class and the caller
* should keep its own handling, NeverType when the type arguments really are disjoint.
*/
private static function mergeGenericObjectTypes(Type $a, Type $b): ?Type
{
if (get_class($a) !== GenericObjectType::class || get_class($b) !== GenericObjectType::class) {
return null;
}
if ($a->getClassName() !== $b->getClassName()) {
return null;
}
if ($a->getSubtractedType() !== null || $b->getSubtractedType() !== null) {
return null;
}

$aTypes = $a->getTypes();
$bTypes = $b->getTypes();
if (count($aTypes) !== count($bTypes)) {
return null;
}

$aVariances = $a->getVariances();
$bVariances = $b->getVariances();
$invariant = TemplateTypeVariance::createInvariant();
foreach (array_keys($aTypes) as $i) {
if (($aVariances[$i] ?? $invariant)->equals($bVariances[$i] ?? $invariant)) {
continue;
}

return null;
}

$newTypes = [];
foreach ($aTypes as $i => $aType) {
$newType = self::intersect($aType, $bTypes[$i]);
if ($newType instanceof NeverType) {
return $newType;
}

$newTypes[] = $newType;
}

return new GenericObjectType(
$a->getClassName(),
$newTypes,
variances: $aVariances === [] ? $bVariances : $aVariances,
);
}

private static function intersectDefiniteConstantArrays(ConstantArrayType $a, ConstantArrayType $b): Type
{
$aSealed = $a->isUnsealed()->no();
Expand Down
4 changes: 0 additions & 4 deletions tests/PHPStan/Analyser/NodeScopeResolverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,6 @@ private static function findTestFiles(): iterable
yield __DIR__ . '/data/enum-reflection-php81.php';
}

if (PHP_VERSION_ID >= 80100 && PHP_VERSION_ID < 80400) {
yield __DIR__ . '/data/enum-reflection-backed.php';
}

if (PHP_VERSION_ID >= 80000 && PHP_VERSION_ID < 80500) {
yield __DIR__ . '/data/bug-13692.php';
}
Expand Down
44 changes: 44 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-15165.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php // lint >= 8.1

namespace Bug15165;

use BackedEnum;
use ReflectionEnum;
use UnitEnum;
use function PHPStan\Testing\assertType;

/**
* @template T of UnitEnum
* @param ReflectionEnum<T> $enum
* @return void
*/
function testUnit(ReflectionEnum $enum): void
{
if ($enum->isBacked()) {
assertType('ReflectionEnum<BackedEnum&T of UnitEnum (function Bug15165\testUnit(), argument)>', $enum);
}
}

/**
* @template T of BackedEnum
* @param ReflectionEnum<T> $enum
* @return void
*/
function testBacked(ReflectionEnum $enum): void
{
if (!$enum->isBacked()) {
assertType('*NEVER*', $enum);
}
}

/**
* @template T of BackedEnum|UnitEnum
* @param ReflectionEnum<T> $enum
* @return void
*/
function testAny(ReflectionEnum $enum): void
{
if ($enum->isBacked()) {
assertType('ReflectionEnum<BackedEnum&T of UnitEnum (function Bug15165\testAny(), argument)>', $enum);
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<?php // lint < 8.4
<?php // lint >= 8.1

namespace EnumReflection;

Expand Down
116 changes: 116 additions & 0 deletions tests/PHPStan/Analyser/nsrt/invariant-generic-narrowing.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<?php declare(strict_types = 1);

namespace InvariantGenericNarrowing;

use function PHPStan\Testing\assertType;

interface Animal {}
interface Cat extends Animal {}

/** @template T of object */
final class Box {}

/** @template-covariant T of object */
final class CoBox {}

/** @template T */
final class Bag {}

/**
* @template T of object
* @param Box<T> $b
* @phpstan-assert Box<T&Cat> $b
*/
function assertCat(Box $b): void {}

/**
* @template T
* @param Bag<T> $b
* @phpstan-assert Bag<string> $b
*/
function assertStringBag(Bag $b): void {}

final class Asserter
{

/**
* @template T of object
* @param Box<T> $b
* @phpstan-assert Box<T&Cat> $b
*/
public function assertCat(Box $b): void {}

/**
* @template T of object
* @param Box<T> $b
* @phpstan-assert Box<T&Cat> $b
*/
public static function assertCatStatic(Box $b): void {}

}

/** @param Box<Animal> $b */
function narrowByFunctionAssert(Box $b): void
{
assertCat($b);
assertType('InvariantGenericNarrowing\Box<InvariantGenericNarrowing\Cat>', $b);
}

/** @param Box<Animal> $b */
function narrowByMethodAssert(Box $b, Asserter $a): void
{
$a->assertCat($b);
assertType('InvariantGenericNarrowing\Box<InvariantGenericNarrowing\Cat>', $b);
}

/** @param Box<Animal> $b */
function narrowByStaticMethodAssert(Box $b): void
{
Asserter::assertCatStatic($b);
assertType('InvariantGenericNarrowing\Box<InvariantGenericNarrowing\Cat>', $b);
}

/**
* @template T of object
* @param Box<T> $b
*/
function narrowTemplateArgument(Box $b): void
{
assertCat($b);
assertType('InvariantGenericNarrowing\Box<InvariantGenericNarrowing\Cat&T of object (function InvariantGenericNarrowing\narrowTemplateArgument(), argument)>', $b);
}

/**
* @param Box<Animal>&Box<Cat> $b
* @param CoBox<Animal>&CoBox<Cat> $c
*/
function intersectionInPhpDoc($b, $c): void
{
assertType('InvariantGenericNarrowing\Box<InvariantGenericNarrowing\Cat>', $b);
assertType('InvariantGenericNarrowing\CoBox<InvariantGenericNarrowing\Cat>', $c);
}

/**
* @template T of object
* @param Box<T> $b
* @param Box<Cat> $c
*/
function identicalComparison(Box $b, Box $c): void
{
if ($b === $c) {
assertType('InvariantGenericNarrowing\Box<InvariantGenericNarrowing\Cat&T of object (function InvariantGenericNarrowing\identicalComparison(), argument)>', $b);
}
}

/**
* @param Bag<int> $b
* @param Bag<int|string> $c
*/
function disjointTypeArgumentsAreStillNever(Bag $b, Bag $c): void
{
assertStringBag($b);
assertType('*NEVER*', $b);

assertStringBag($c);
assertType('InvariantGenericNarrowing\Bag<string>', $c);
}
Original file line number Diff line number Diff line change
Expand Up @@ -1398,4 +1398,17 @@ public function testBug6211(): void
]);
}

public function testInvariantGenericAssert(): void
{
$this->treatPhpDocTypesAsCertain = true;
$tipText = 'Because the type is coming from a PHPDoc, you can turn off this check by setting <fg=cyan>treatPhpDocTypesAsCertain: false</> in your <fg=cyan>%configurationFile%</>.';
$this->analyse([__DIR__ . '/data/invariant-generic-assert.php'], [
[
'Call to function assertStringBag() with InvariantGenericAssert\Bag<int> will always evaluate to false.',
82,
$tipText,
],
]);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,26 @@ public function testInTrait(): void
]);
}

#[RequiresPhp('>= 8.1.0')]
public function testBug15165(): void
{
$this->treatPhpDocTypesAsCertain = true;
$this->analyse([__DIR__ . '/data/bug-15165.php'], []);
}

public function testInvariantGenericAssert(): void
{
$this->treatPhpDocTypesAsCertain = true;
$tipText = 'Because the type is coming from a PHPDoc, you can turn off this check by setting <fg=cyan>treatPhpDocTypesAsCertain: false</> in your <fg=cyan>%configurationFile%</>.';
$this->analyse([__DIR__ . '/data/invariant-generic-assert.php'], [
[
'Call to method InvariantGenericAssert\Asserter::assertStringBag() with InvariantGenericAssert\Bag<int> will always evaluate to false.',
88,
$tipText,
],
]);
}

public static function getAdditionalConfigFiles(): array
{
return [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,19 @@ public function testInTrait(): void
]);
}

public function testInvariantGenericAssert(): void
{
$this->treatPhpDocTypesAsCertain = true;
$tipText = 'Because the type is coming from a PHPDoc, you can turn off this check by setting <fg=cyan>treatPhpDocTypesAsCertain: false</> in your <fg=cyan>%configurationFile%</>.';
$this->analyse([__DIR__ . '/data/invariant-generic-assert.php'], [
[
'Call to static method InvariantGenericAssert\Asserter::assertStringBagStatic() with InvariantGenericAssert\Bag<int> will always evaluate to false.',
94,
$tipText,
],
]);
}

public static function getAdditionalConfigFiles(): array
{
return [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1291,4 +1291,15 @@ public function testBug14847(): void
]);
}

public function testInvariantGenericComparison(): void
{
$this->analyse([__DIR__ . '/data/invariant-generic-comparison.php'], [
[
'Strict comparison using === between InvariantGenericComparison\Bag<int> and InvariantGenericComparison\Bag<string> will always evaluate to false.',
43,
'Because the type is coming from a PHPDoc, you can turn off this check by setting <fg=cyan>treatPhpDocTypesAsCertain: false</> in your <fg=cyan>%configurationFile%</>.',
],
]);
}

}
Loading
Loading