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
19 changes: 17 additions & 2 deletions src/Type/ConditionalType.php
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ public function traverse(callable $cb): Type
return $this;
}

return new self($subject, $target, $if, $else, $this->negated);
return $this->withNormalizedBranches($subject, $target, $if, $else);
}

public function traverseSimultaneously(Type $right, callable $cb): Type
Expand All @@ -176,7 +176,7 @@ public function traverseSimultaneously(Type $right, callable $cb): Type
return $this;
}

return new self($subject, $target, $if, $else, $this->negated);
return $this->withNormalizedBranches($subject, $target, $if, $else);
}

public function toPhpDocNode(): TypeNode
Expand All @@ -190,6 +190,21 @@ public function toPhpDocNode(): TypeNode
);
}

/**
* The branches passed in are images of normalized branches, so the references to the
* subject in them have already been narrowed, and follow whatever the subject maps to.
* Normalizing them again against the mapped subject would narrow unrelated types that
* happen to equal it, and re-traverse every conditional type nested in the branches.
*/
private function withNormalizedBranches(Type $subject, Type $target, Type $if, Type $else): self
{
$type = new self($subject, $target, $if, $else, $this->negated);
$type->normalizedIf = $if;
$type->normalizedElse = $else;

return $type;
}

private function getNormalizedIf(): Type
{
return $this->normalizedIf ??= $this->narrowSubjectIn($this->if, !$this->negated);
Expand Down
33 changes: 26 additions & 7 deletions src/Type/ConditionalTypeForParameter.php
Original file line number Diff line number Diff line change
Expand Up @@ -115,21 +115,40 @@ public static function resolveInType(Type $type, callable $getSubjectType): Type
if ($type instanceof self) {
$subjectType = $getSubjectType($type->getParameterName());
if ($subjectType !== null) {
// Traverse children first, then convert — avoids infinite loop when
// Resolve the branches first, then convert — avoids infinite loop when
// the subject contains a ConditionalTypeForParameter with a colliding parameter name.
$type = $traverse($type);
if ($type instanceof self) {
return $type->toConditional($subjectType);
}

return $type;
return $type->resolveWithSubject($subjectType, $getSubjectType);
}
}

return $traverse($type);
});
}

/**
* A condition on the same parameter nested in a branch sees the subject as the branch
* knows it: `subject & target` where this condition holds, `subject ~ target` where it
* does not. `($key is Model ? A : ($key is Arrayable ? B : C))` asks whether the
* non-Model part of the argument is Arrayable.
*
* @param callable(string): ?Type $getSubjectType
*/
private function resolveWithSubject(Type $subject, callable $getSubjectType): Type
{
$target = self::resolveInType($this->target, $getSubjectType);
$getNarrowedSubjectType = fn (bool $conditionHolds): callable => fn (string $parameterName): ?Type => $parameterName === $this->parameterName
? ($conditionHolds ? TypeCombinator::intersect($subject, $target) : TypeCombinator::remove($subject, $target))
: $getSubjectType($parameterName);

return new ConditionalType(
$subject,
$target,
self::resolveInType($this->getNormalizedIf(), $getNarrowedSubjectType(!$this->negated)),
self::resolveInType($this->getNormalizedElse(), $getNarrowedSubjectType($this->negated)),
$this->negated,
);
}

public function toConditional(Type $subject): Type
{
return new ConditionalType(
Expand Down
9 changes: 9 additions & 0 deletions tests/PHPStan/Analyser/AnalyserIntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1699,6 +1699,15 @@ public function testBug15252AnalysedFileWithInvalidClassName(): void
$this->assertSame(10, $errors[0]->getLine());
}

#[RequiresPhp('>= 8.1.0')]
public function testBug15310(): void
{
$errors = $this->runAnalyse(__DIR__ . '/data/bug-15310.php');
$this->assertCount(1, $errors);
$this->assertSame('Function Bug15310\\reproduce() return type with generic class Bug15310\\ExampleType does not specify its types: TValue', $errors[0]->getMessage());
$this->assertSame(39, $errors[0]->getLine());
}

/**
* @param string[]|null $allAnalysedFiles
* @return list<Error>
Expand Down
42 changes: 42 additions & 0 deletions tests/PHPStan/Analyser/data/bug-15310.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php // lint >= 8.1

namespace Bug15310;

enum ExampleIdentifier { case A; case B; case C; case D; case E; case F; case G; case H; case I; case J; case K; case L; case M; case N; case O; }

/** @template-covariant TValue */
abstract class ExampleType
{
}

/**
* @template T of ExampleIdentifier
* @extends ExampleType<(
* T is ExampleIdentifier::A ? array :
* T is ExampleIdentifier::B ? bool :
* T is ExampleIdentifier::C ? callable :
* T is ExampleIdentifier::D ? false :
* T is ExampleIdentifier::E ? float :
* T is ExampleIdentifier::F ? int :
* T is ExampleIdentifier::G ? iterable :
* T is ExampleIdentifier::H ? mixed :
* T is ExampleIdentifier::I ? null :
* T is ExampleIdentifier::J ? object :
* T is ExampleIdentifier::K ? resource :
* T is ExampleIdentifier::L ? string :
* T is ExampleIdentifier::M ? true :
* T is ExampleIdentifier::N ? never :
* T is ExampleIdentifier::O ? void :
* mixed
* )>
*/
final class ExampleBuiltinType extends ExampleType
{
/** @param T $identifier */
public function __construct(public ExampleIdentifier $identifier) {}
}

function reproduce(): ExampleType
{
return new ExampleBuiltinType(ExampleIdentifier::A);
}
67 changes: 67 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-15296.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php // lint >= 8.0

declare(strict_types = 1);

namespace Bug15296;

use function PHPStan\Testing\assertType;

interface Arrayable {}
class Model implements Arrayable {}
final class User extends Model {}

/** @template TModel of Model */
final class Collection
{
/**
* @return ($key is Model ? TModel|null : ($key is (Arrayable|array<mixed>) ? static : TModel|null))
*/
public function find(mixed $key): mixed
{
return null;
}
}

/** @param Collection<User> $collection */
function test(Collection $collection, ?User $user): void
{
assertType('Bug15296\User|null', $collection->find($user));
}

class Variants
{

/**
* @return ($key is not Model ? ($key is Arrayable ? 'arrayable' : 'other') : 'model')
*/
public function negated(mixed $key): string
{
return 'other';
}

/**
* @return ($key is Model ? ($key is User ? 'user' : 'model') : 'other')
*/
public function inIf(mixed $key): string
{
return 'other';
}

/**
* @return ($key is User ? 'user' : ($key is Model ? 'model' : ($key is null ? 'null' : 'other')))
*/
public function deep(mixed $key): string
{
return 'other';
}

}

function testVariants(Variants $v, ?User $user, Model|int $modelOrInt, User|Model|null $any): void
{
assertType("'model'|'other'", $v->negated($modelOrInt));
assertType("'model'", $v->negated($user ?? new User()));
assertType("'other'|'user'", $v->inIf($user));
assertType("'model'|'other'|'user'", $v->inIf($modelOrInt));
assertType("'model'|'null'|'user'", $v->deep($any));
}
Loading