Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Mapping closed inheritance to union during assertion #9829

Merged
merged 3 commits into from
May 29, 2023
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
81 changes: 81 additions & 0 deletions src/Psalm/Internal/Type/ClosedInheritanceToUnion.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

declare(strict_types=1);

namespace Psalm\Internal\Type;

use Psalm\Codebase;
use Psalm\Type\Atomic\TGenericObject;
use Psalm\Type\Atomic\TNamedObject;
use Psalm\Type\Union;

use function array_keys;

/**
* @internal
*/
final class ClosedInheritanceToUnion
{
public static function map(Union $input, Codebase $codebase): Union
{
$new_types = [];
$meet_inheritors = false;

foreach ($input->getAtomicTypes() as $atomic_type) {
if ($atomic_type instanceof TNamedObject) {
$storage = $codebase->classlikes->getStorageFor($atomic_type->value);

if (null === $storage || null === $storage->inheritors) {
$new_types[] = $atomic_type;
continue;
}

$template_result = self::getTemplateResult($atomic_type, $codebase);

$replaced_inheritors = TemplateInferredTypeReplacer::replace(
$storage->inheritors,
$template_result,
$codebase,
);

foreach ($replaced_inheritors->getAtomicTypes() as $replaced_atomic_type) {
$new_types[] = $replaced_atomic_type;
}

$meet_inheritors = true;
} else {
$new_types[] = $atomic_type;
}
}

if (!$meet_inheritors) {
return $input;
}

return $new_types ? $input->setTypes($new_types) : $input;
}

private static function getTemplateResult(TNamedObject $object, Codebase $codebase): TemplateResult
{
if (!$object instanceof TGenericObject) {
return new TemplateResult([], []);
}

$storage = $codebase->classlikes->getStorageFor($object->value);

if (null === $storage || null === $storage->template_types) {
return new TemplateResult([], []);
}

$lower_bounds = [];
$offset = 0;

foreach ($storage->template_types as $template_name => $templates) {
foreach (array_keys($templates) as $defining_class) {
$lower_bounds[$template_name][$defining_class] = $object->type_params[$offset++];
}
}

return new TemplateResult($storage->template_types, $lower_bounds);
}
}
5 changes: 5 additions & 0 deletions src/Psalm/Internal/Type/NegatedAssertionReconciler.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ public static function reconcile(
int &$failed_reconciliation,
bool $inside_loop
): Union {
$existing_var_type = ClosedInheritanceToUnion::map(
$existing_var_type,
$statements_analyzer->getCodebase(),
);

$is_equality = $assertion->hasEquality();

$assertion_type = $assertion->getAtomicType();
Expand Down
264 changes: 264 additions & 0 deletions tests/AssertAnnotationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2247,6 +2247,270 @@ function isNonEmptyString($_str): bool
return true;
}',
],
'assertObjectWithClosedInheritance' => [
'code' => '<?php
/**
* @psalm-inheritors FirstChoice|SecondChoice|ThirdChoice
*/
interface Choice
{
}

final class FirstChoice implements Choice
{
}

final class SecondChoice implements Choice
{
}

final class ThirdChoice implements Choice
{
}

/**
* @psalm-assert-if-true FirstChoice $choice
*/
function isFirstChoice(Choice $choice): bool
{
return $choice instanceof FirstChoice;
}

/**
* @psalm-assert-if-true SecondChoice $choice
*/
function isSecondChoice(Choice $choice): bool
{
return $choice instanceof SecondChoice;
}

function testFirstChoice(Choice $choice): void
{
if (isFirstChoice($choice)) {
/** @psalm-check-type-exact $choice = FirstChoice */
} else {
/** @psalm-check-type-exact $choice = SecondChoice|ThirdChoice */
}
}

function testFirstAndSecondChoice(Choice $choice): void
{
if (isFirstChoice($choice)) {
/** @psalm-check-type-exact $choice = FirstChoice */
} elseif (isSecondChoice($choice)) {
/** @psalm-check-type-exact $choice = SecondChoice */
} else {
/** @psalm-check-type-exact $choice = ThirdChoice */
}
}',
],
'assertObjectWithClosedInheritanceWithMatch' => [
'code' => '<?php
/**
* @psalm-inheritors FirstChoice|SecondChoice|ThirdChoice
*/
interface Choice
{
}

final class FirstChoice implements Choice {}
final class SecondChoice implements Choice {}
final class ThirdChoice implements Choice {}

/**
* @psalm-assert-if-true FirstChoice $choice
*/
function isFirstChoice(Choice $choice): bool
{
return $choice instanceof FirstChoice;
}

/**
* @psalm-assert-if-true SecondChoice $choice
*/
function isSecondChoice(Choice $choice): bool
{
return $choice instanceof SecondChoice;
}

function testFirstChoice(FirstChoice $_first): string
{
return "first";
}

function testSecondOrThirdChoice(SecondChoice|ThirdChoice $_first): string
{
return "second or third";
}

function getLabel(Choice $choice): string
{
return match (true) {
isFirstChoice($choice) => testFirstChoice($choice),
default => testSecondOrThirdChoice($choice),
};
}',
'assertions' => [],
'ignored_issues' => [],
'php_version' => '8.1',
],
'assertTemplatedObjectWithClosedInheritance' => [
'code' => '<?php
/**
* @template-covariant E
* @template-covariant A
* @psalm-inheritors Left<E> | Right<A>
*/
interface Either {
/** @psalm-assert-if-true Left<E> $this */
public function isLeft(): bool;

/** @psalm-assert-if-true Right<A> $this */
public function isRight(): bool;
}

/**
* @template E
* @implements Either<E, never>
*/
final class Left implements Either {
public function isLeft(): bool
{
return true;
}
public function isRight(): bool
{
return false;
}
}

/**
* @template A
* @implements Either<never, A>
*/
final class Right implements Either {
public function isLeft(): bool
{
return false;
}
public function isRight(): bool
{
return true;
}
}

/**
* @template E
* @template A
* @param Either<E, A> $either
* @psalm-assert-if-true Left<E> $either
*/
function isLeft(Either $either): bool
{
return $either instanceof Left;
}

/**
* @template E
* @template A
* @param Either<E, A> $either
* @psalm-assert-if-true Right<A> $either
*/
function isRight(Either $either): bool
{
return $either instanceof Right;
}

/**
* @return Either<OutOfRangeException, int>
*/
function getEither(): Either
{
throw new RuntimeException("???");
}

/**
* @param Left<OutOfRangeException> $_left
*/
function testLeft(Left $_left): void {}

/**
* @param Right<int> $_right
*/
function testRight(Right $_right): void {}

/** @param Either<OutOfRangeException, int> $either */
function isLeftFunctionIfElse(Either $either): void
{
if (isLeft($either)) {
/** @psalm-check-type-exact $either = Left<OutOfRangeException> */
testLeft($either);
} else {
/** @psalm-check-type-exact $either = Right<int> */
testRight($either);
}
}

/** @param Either<OutOfRangeException, int> $either */
function isRightFunctionIfElse(Either $either): void
{
if (isRight($either)) {
testRight($either);
/** @psalm-check-type-exact $either = Right<int> */
} else {
/** @psalm-check-type-exact $either = Left<OutOfRangeException> */
testLeft($either);
}
}

/** @param Either<OutOfRangeException, int> $either */
function testRightFunctionTernary(Either $either): void
{
isRight($either) ? testRight($either) : testLeft($either);
}

/** @param Either<OutOfRangeException, int> $either */
function testLeftFunctionTernary(Either $either): void
{
isLeft($either) ? testLeft($either) : testRight($either);
}

/** @param Either<OutOfRangeException, int> $either */
function isLeftMethodIfElse(Either $either): void
{
if ($either->isLeft()) {
/** @psalm-check-type-exact $either = Left<OutOfRangeException> */
testLeft($either);
} else {
/** @psalm-check-type-exact $either = Right<int> */
testRight($either);
}
}

/** @param Either<OutOfRangeException, int> $either */
function isRightMethodIfElse(Either $either): void
{
if ($either->isRight()) {
testRight($either);
/** @psalm-check-type-exact $either = Right<int> */
} else {
/** @psalm-check-type-exact $either = Left<OutOfRangeException> */
testLeft($either);
}
}

/** @param Either<OutOfRangeException, int> $either */
function testRightMethodTernary(Either $either): void
{
$either->isRight() ? testRight($either) : testLeft($either);
}

/** @param Either<OutOfRangeException, int> $either */
function testLeftMethodTernary(Either $either): void
{
$either->isLeft() ? testLeft($either) : testRight($either);
}',
],
];
}

Expand Down