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
111 changes: 111 additions & 0 deletions src/Analyser/ConditionalTypeResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php declare(strict_types = 1);

namespace PHPStan\Analyser;

use PhpParser\Node\Arg;
use PhpParser\Node\Expr\Variable;
use PHPStan\Reflection\GenericParametersAcceptorResolver;
use PHPStan\Reflection\ParametersAcceptor;
use PHPStan\Reflection\ResolvedFunctionVariant;
use PHPStan\Type\ConditionalTypeForParameter;
use PHPStan\Type\Type;
use PHPStan\Type\TypeUtils;
use function substr;

/**
* Resolves conditional types like `($x is 0 ? Exception : void)` and
* `(TKey is int ? void : Exception)` declared in PHPDoc tags that live on the
* function or method reflection instead of on the ParametersAcceptor - `@throws`
* and `@phpstan-self-out`.
*
* Tags carried by the ParametersAcceptor (`@return`, `@param`, `@param-out`,
* `@param-closure-this`) do not need this: ParametersAcceptorSelector::selectFromArgs()
* already hands back a ResolvedFunctionVariant that resolves them. `@phpstan-assert`
* does its own resolution in TypeSpecifier because its subjects are argument
* expressions rather than types.
*
* Either side of the resolution is supported: against the arguments passed at a
* call site (so callers see the branch their arguments select), or against the
* parameter variables inside the function body.
*/
final class ConditionalTypeResolver
{

/**
* Resolves a conditional type against a call site. A `ResolvedFunctionVariant`
* already holds the call's bound arguments and inferred template types and knows how to
* resolve a conditional type the same way it resolves a conditional return type — both
* `ConditionalTypeForParameter` (e.g. `($x is 0 ? Exception : void)`) and `ConditionalType`
* whose subject is a template type (e.g. `(TKey is int ? void : Exception)`).
*
* `ParametersAcceptorSelector::selectFromArgs()` only resolves the variant when the return
* or parameter types are conditional/generic — it does not know about the types declared by
* the tags resolved here — so the variant is resolved from the passed arguments via
* `GenericParametersAcceptorResolver`.
*
* @param Arg[] $args
*/
public static function resolveForCall(
Type $declaredType,
ParametersAcceptor $parametersAcceptor,
array $args,
Scope $scope,
): Type
{
if (!$declaredType->hasTemplateOrLateResolvableType()) {
return $declaredType;
}

// A variant already bound to this call's arguments has the template types inferred
// from everything the call knows - including a closure argument's return type, which
// the argument type alone no longer tells - so it resolves the type as it is.
if ($parametersAcceptor instanceof ResolvedFunctionVariant && $parametersAcceptor->hasBoundArgs()) {
return $parametersAcceptor->resolveConditionalTypes($declaredType);
}

// Otherwise the acceptor is not bound to this call (an unresolved acceptor, or a method
// variant resolved only against the generics of the class it is called on), so the
// variant is resolved here from this call's argument types.
$originalAcceptor = $parametersAcceptor instanceof ResolvedFunctionVariant
? $parametersAcceptor->getOriginalParametersAcceptor()
: $parametersAcceptor;

$argTypes = [];
foreach ($args as $i => $arg) {
$argTypes[$arg->name !== null ? $arg->name->toString() : $i] = $scope->getType($arg->value);
}

$resolvedAcceptor = GenericParametersAcceptorResolver::resolve($argTypes, $originalAcceptor);
if (!$resolvedAcceptor instanceof ResolvedFunctionVariant) {
return $declaredType;
}

return $resolvedAcceptor->resolveConditionalTypes($declaredType);
}

public static function resolveForScope(Type $declaredType, Scope $scope): Type
{
if (!$declaredType->hasTemplateOrLateResolvableType()) {
return $declaredType;
}

$declaredType = ConditionalTypeForParameter::resolveInType(
$declaredType,
static function (string $parameterName) use ($scope): ?Type {
$variableName = substr($parameterName, 1);
if (!$scope->hasVariableType($variableName)->yes()) {
return null;
}

return $scope->getType(new Variable($variableName));
},
);

// A ConditionalType whose subject is a template type cannot be resolved to a single
// branch inside the function body (the template is not bound to a concrete type there),
// so it is conservatively collapsed to the union of its branches — the broadest type the
// declaration permits — rather than left as a Maybe-certain conditional.
return TypeUtils::resolveLateResolvableTypes($declaredType, true);
}

}
4 changes: 4 additions & 0 deletions src/Analyser/ExprHandler/FuncCallHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use PhpParser\Node\Scalar\String_;
use PhpParser\Node\Stmt;
use PHPStan\Analyser\ArgumentsNormalizer;
use PHPStan\Analyser\ConditionalTypeResolver;
use PHPStan\Analyser\ExpressionContext;
use PHPStan\Analyser\ExpressionResult;
use PHPStan\Analyser\ExpressionResultFactory;
Expand Down Expand Up @@ -373,6 +374,9 @@ private function getFunctionThrowPoint(
}

$throwType = $functionReflection->getThrowType();
if ($throwType !== null && $parametersAcceptor !== null) {
$throwType = ConditionalTypeResolver::resolveForCall($throwType, $parametersAcceptor, $normalizedFuncCall->getArgs(), $scope);
}
if ($throwType === null) {
$returnType = $scope->getType($normalizedFuncCall);
if ($returnType instanceof NeverType && $returnType->isExplicit()) {
Expand Down
4 changes: 4 additions & 0 deletions src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Identifier;
use PHPStan\Analyser\ConditionalTypeResolver;
use PHPStan\Analyser\ExpressionContext;
use PHPStan\Analyser\InternalThrowPoint;
use PHPStan\Analyser\MutatingScope;
Expand Down Expand Up @@ -90,6 +91,9 @@ public function getThrowPoint(
}

$throwType = $methodReflection->getThrowType();
if ($throwType !== null) {
$throwType = ConditionalTypeResolver::resolveForCall($throwType, $parametersAcceptor, $normalizedMethodCall->getArgs(), $scope);
}
if ($throwType === null) {
$returnType = $scope->getType($normalizedMethodCall);
if ($returnType instanceof NeverType && $returnType->isExplicit()) {
Expand Down
8 changes: 2 additions & 6 deletions src/Analyser/ExprHandler/MethodCallHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use PhpParser\Node\Stmt;
use PHPStan\Analyser\ArgumentsNormalizer;
use PHPStan\Analyser\CalledMethodProcessor;
use PHPStan\Analyser\ConditionalTypeResolver;
use PHPStan\Analyser\ExpressionContext;
use PHPStan\Analyser\ExpressionResult;
use PHPStan\Analyser\ExpressionResultFactory;
Expand Down Expand Up @@ -197,12 +198,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
$acceptorForGenerics = $resolvedParametersAcceptor ?? $parametersAcceptor;
$scope = $scope->assignExpression(
$normalizedExpr->var,
TemplateTypeHelper::resolveTemplateTypes(
$selfOutType,
$acceptorForGenerics->getResolvedTemplateTypeMap(),
$acceptorForGenerics instanceof ExtendedParametersAcceptor ? $acceptorForGenerics->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(),
TemplateTypeVariance::createCovariant(),
),
ConditionalTypeResolver::resolveForCall($selfOutType, $acceptorForGenerics, $normalizedExpr->getArgs(), $scope),
$scope->getNativeType($normalizedExpr->var),
);
}
Expand Down
3 changes: 2 additions & 1 deletion src/Analyser/ExprHandler/NewHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use PhpParser\Node\Name;
use PhpParser\Node\Stmt;
use PHPStan\Analyser\ArgumentsNormalizer;
use PHPStan\Analyser\ConditionalTypeResolver;
use PHPStan\Analyser\ExpressionContext;
use PHPStan\Analyser\ExpressionResult;
use PHPStan\Analyser\ExpressionResultFactory;
Expand Down Expand Up @@ -333,7 +334,7 @@ private function getConstructorThrowPoint(MethodReflection $constructorReflectio
}

if ($constructorReflection->getThrowType() !== null) {
$throwType = $constructorReflection->getThrowType();
$throwType = ConditionalTypeResolver::resolveForCall($constructorReflection->getThrowType(), $parametersAcceptor, $args, $scope);
if (!$throwType->isVoid()->yes()) {
return InternalThrowPoint::createExplicit($scope, $throwType, $new, true);
}
Expand Down
23 changes: 9 additions & 14 deletions src/Analyser/TypeSpecifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use PHPStan\Type\TypeTraverser;
use function array_key_exists;
use function array_last;
use function array_map;
use function array_merge;
Expand Down Expand Up @@ -430,21 +429,17 @@ public function specifyTypesFromAsserts(TypeSpecifierContext $context, Expr\Call

foreach ($asserts as $assert) {
foreach ($argsMap[substr($assert->getParameter()->getParameterName(), 1)] ?? [] as $parameterExpr) {
$assertedType = TypeTraverser::map($assert->getType(), static function (Type $type, callable $traverse) use ($argsMap, $scope): Type {
if ($type instanceof ConditionalTypeForParameter) {
$parameterName = substr($type->getParameterName(), 1);
if (array_key_exists($parameterName, $argsMap)) {
$type = $traverse($type);
if ($type instanceof ConditionalTypeForParameter) {
$argType = TypeCombinator::union(...array_map(static fn (Expr $expr) => $scope->getType($expr), $argsMap[substr($type->getParameterName(), 1)]));
return $type->toConditional($argType);
}
return $type;
$assertedType = ConditionalTypeForParameter::resolveInType(
$assert->getType(),
static function (string $parameterName) use ($argsMap, $scope): ?Type {
$parameterExprs = $argsMap[substr($parameterName, 1)] ?? null;
if ($parameterExprs === null) {
return null;
}
}

return $traverse($type);
});
return TypeCombinator::union(...array_map(static fn (Expr $expr) => $scope->getType($expr), $parameterExprs));
},
);

$assertExpr = $assert->getParameter()->getExpr($parameterExpr);

Expand Down
13 changes: 10 additions & 3 deletions src/PhpDoc/ResolvedPhpDocBlock.php
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ public function merge(ResolvedPhpDocBlock $parent, InheritedPhpDocParameterMappi
$result->paramsPureUnlessCallableIsImpure = self::mergeParamsPureUnlessCallableIsImpure($this->getParamsPureUnlessCallableIsImpure(), $parent, $parameterMapping);
$result->paramClosureThisTags = self::mergeParamClosureThisTags($this->getParamClosureThisTags(), $parent, $parameterMapping, $parentClass);
$result->returnTag = self::mergeReturnTags($this->getReturnTag(), $declaringClass, $parent, $parameterMapping, $parentClass);
$result->throwsTag = self::mergeThrowsTags($this->getThrowsTag(), $parent);
$result->throwsTag = self::mergeThrowsTags($this->getThrowsTag(), $parent, $parameterMapping);
$result->mixinTags = $this->getMixinTags();
$result->requireExtendsTags = $this->getRequireExtendsTags();
$result->requireImplementsTags = $this->getRequireImplementsTags();
Expand Down Expand Up @@ -1038,13 +1038,20 @@ private static function mergeDeprecatedTags(?DeprecatedTag $deprecatedTag, bool
return $result;
}

private static function mergeThrowsTags(?ThrowsTag $throwsTag, self $parent): ?ThrowsTag
private static function mergeThrowsTags(?ThrowsTag $throwsTag, self $parent, InheritedPhpDocParameterMapping $parameterMapping): ?ThrowsTag
{
if ($throwsTag !== null) {
return $throwsTag;
}

return $parent->getThrowsTag();
$parentThrowsTag = $parent->getThrowsTag();
if ($parentThrowsTag === null) {
return null;
}

// Conditional @throws types like ($x is 0 ? Exception : void) reference parameter
// names that may differ in the overriding method, so remap them just like @return.
return new ThrowsTag($parameterMapping->transformConditionalReturnTypeWithParameterNameMapping($parentThrowsTag->getType()));
}

/**
Expand Down
13 changes: 13 additions & 0 deletions src/Reflection/ResolvedFunctionVariant.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,17 @@ public function getOriginalParametersAcceptor(): ParametersAcceptor;

public function getReturnTypeWithUnresolvableTemplateTypes(): Type;

/**
* Whether the variant is bound to the arguments of a specific call, as opposed to being
* resolved only against the generics of the class the method is called on.
*/
public function hasBoundArgs(): bool;

/**
* Resolves an arbitrary declared type (e.g. a conditional `@throws` or `@phpstan-self-out`
* type) against this call's bound arguments and inferred template types, the same way the
* return type is resolved at the call site.
*/
public function resolveConditionalTypes(Type $type): Type;

}
10 changes: 10 additions & 0 deletions src/Reflection/ResolvedFunctionVariantWithCallable.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,16 @@ public function getReturnType(): Type
return $this->parametersAcceptor->getReturnType();
}

public function hasBoundArgs(): bool
{
return $this->parametersAcceptor->hasBoundArgs();
}

public function resolveConditionalTypes(Type $type): Type
{
return $this->parametersAcceptor->resolveConditionalTypes($type);
}

public function getPhpDocReturnType(): Type
{
return $this->parametersAcceptor->getPhpDocReturnType();
Expand Down
36 changes: 22 additions & 14 deletions src/Reflection/ResolvedFunctionVariantWithOriginal.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
use PHPStan\Type\Type;
use PHPStan\Type\TypeTraverser;
use PHPStan\Type\TypeUtils;
use function array_key_exists;
use function array_map;

final class ResolvedFunctionVariantWithOriginal implements ResolvedFunctionVariant
Expand Down Expand Up @@ -209,6 +208,24 @@ public function getNativeReturnType(): Type
return $this->parametersAcceptor->getNativeReturnType();
}

public function hasBoundArgs(): bool
{
return $this->passedArgs !== [];
}

public function resolveConditionalTypes(Type $type): Type
{
return TypeUtils::resolveLateResolvableTypes(
TemplateTypeHelper::resolveTemplateTypes(
$this->resolveConditionalTypesForParameter($type),
$this->resolvedTemplateTypeMap,
$this->callSiteVarianceMap,
TemplateTypeVariance::createCovariant(),
),
false,
);
}

private function resolveResolvableTemplateTypes(Type $type, TemplateTypeVariance $positionVariance): Type
{
$references = $type->getReferencedTemplateTypes($positionVariance);
Expand Down Expand Up @@ -392,19 +409,10 @@ private static function referencesTemplateType(Type $type, TemplateType $templat

private function resolveConditionalTypesForParameter(Type $type): Type
{
return TypeTraverser::map($type, function (Type $type, callable $traverse): Type {
if ($type instanceof ConditionalTypeForParameter && array_key_exists($type->getParameterName(), $this->passedArgs)) {
// Traverse children first, then convert — avoids infinite loop when
// the passed argument contains ConditionalTypeForParameter with a colliding parameter name.
$type = $traverse($type);
if ($type instanceof ConditionalTypeForParameter) {
return $type->toConditional($this->passedArgs[$type->getParameterName()]);
}
return $type;
}

return $traverse($type);
});
return ConditionalTypeForParameter::resolveInType(
$type,
fn (string $parameterName): ?Type => $this->passedArgs[$parameterName] ?? null,
);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace PHPStan\Rules\Exceptions;

use PhpParser\Node;
use PHPStan\Analyser\ConditionalTypeResolver;
use PHPStan\Analyser\ThrowPoint;
use PHPStan\DependencyInjection\AutowiredParameter;
use PHPStan\DependencyInjection\AutowiredService;
Expand Down Expand Up @@ -41,11 +42,15 @@ public function check(?Type $throwType, array $throwPoints): array
continue;
}

// Conditional @throws types like ($x is 0 ? Exception : void) are resolved
// against the parameter variables narrowed in the scope of the throw point.
$resolvedThrowType = ConditionalTypeResolver::resolveForScope($throwType, $throwPoint->getScope());

foreach (TypeUtils::flattenTypes($throwPoint->getType()) as $throwPointType) {
if ($throwPointType->isSuperTypeOf(new ObjectType(Throwable::class))->yes()) {
continue;
}
if ($throwType->isSuperTypeOf($throwPointType)->yes()) {
if ($resolvedThrowType->isSuperTypeOf($throwPointType)->yes()) {
continue;
}

Expand Down
Loading
Loading