Skip to content

Commit 4f7c44c

Browse files
ondrejmirtesclaude
andcommitted
Support type predicates on callable and Closure types
A conditional return type referencing the callable's own parameter, like @PARAM callable(mixed $value): ($value is int ? true : false) $predicate now declares a type predicate (TypeScript's "$value is int"), represented as @phpstan-assert-if-true-style assertions on CallableType/ClosureType: - Invoking such a callable in a condition narrows the passed argument in both branches, including narrowing to template types of the enclosing function. - Template types in the asserted type are inferred from the assertions of a passed callable, enabling filter()-style generics. - First-class callables inherit predicates both from @phpstan-assert-* tags and from conditional return types in function signatures, so is_int(...), is_string(...) etc. work as predicates out of the box. - Closures and arrow functions with their own @phpstan-assert-* PHPDoc now carry those assertions on their ClosureType. - One-sided predicates are expressed as ($value is int ? bool : false) (only true is conclusive) and ($value is int ? true : bool) (only false is conclusive). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 86f2378 commit 4f7c44c

13 files changed

Lines changed: 1018 additions & 14 deletions

‎src/PhpDoc/TypeNodeResolver.php‎

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
use PHPStan\PhpDocParser\Ast\Type\ThisTypeNode;
4141
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
4242
use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode;
43+
use PHPStan\Reflection\Assertions;
4344
use PHPStan\Reflection\Callables\SimpleImpurePoint;
4445
use PHPStan\Reflection\InitializerExprContext;
4546
use PHPStan\Reflection\InitializerExprTypeResolver;
@@ -60,6 +61,7 @@
6061
use PHPStan\Type\ArrayType;
6162
use PHPStan\Type\BenevolentUnionType;
6263
use PHPStan\Type\BooleanType;
64+
use PHPStan\Type\CallableAssertionsHelper;
6365
use PHPStan\Type\CallableType;
6466
use PHPStan\Type\ClassConstantAccessType;
6567
use PHPStan\Type\ClassStringType;
@@ -1067,15 +1069,20 @@ function (CallableTypeParameterNode $parameterNode) use ($nameScope, &$isVariadi
10671069
$typeNode->parameters,
10681070
));
10691071

1070-
$returnType = $this->resolve($typeNode->returnType, $nameScope);
1072+
$assertions = $this->resolveCallableReturnTypeAssertions($typeNode, $nameScope, $parameters);
1073+
if ($assertions !== null) {
1074+
$returnType = new BooleanType();
1075+
} else {
1076+
$returnType = $this->resolve($typeNode->returnType, $nameScope);
1077+
}
10711078

10721079
if ($mainType instanceof CallableType) {
10731080
$pure = $mainType->isPure();
10741081
if ($pure->yes() && $returnType->isVoid()->yes()) {
10751082
return new ErrorType();
10761083
}
10771084

1078-
return new CallableType($parameters, $returnType, $isVariadic, $templateTypeMap, templateTags: $templateTags, isPure: $pure);
1085+
return new CallableType($parameters, $returnType, $isVariadic, $templateTypeMap, templateTags: $templateTags, isPure: $pure, assertions: $assertions);
10791086

10801087
} elseif (
10811088
$mainType instanceof ObjectType
@@ -1087,9 +1094,9 @@ function (CallableTypeParameterNode $parameterNode) use ($nameScope, &$isVariadi
10871094
'call to a Closure',
10881095
false,
10891096
),
1090-
]);
1097+
], assertions: $assertions);
10911098
} elseif ($mainType instanceof ClosureType) {
1092-
$closure = new ClosureType($parameters, $returnType, $isVariadic, $templateTypeMap, templateTags: $templateTags, impurePoints: $mainType->getImpurePoints(), invalidateExpressions: $mainType->getInvalidateExpressions(), usedVariables: $mainType->getUsedVariables(), acceptsNamedArguments: $mainType->acceptsNamedArguments(), mustUseReturnValue: $mainType->mustUseReturnValue(), isStatic: $mainType->isStaticClosure());
1099+
$closure = new ClosureType($parameters, $returnType, $isVariadic, $templateTypeMap, templateTags: $templateTags, impurePoints: $mainType->getImpurePoints(), invalidateExpressions: $mainType->getInvalidateExpressions(), usedVariables: $mainType->getUsedVariables(), acceptsNamedArguments: $mainType->acceptsNamedArguments(), mustUseReturnValue: $mainType->mustUseReturnValue(), assertions: $assertions, isStatic: $mainType->isStaticClosure());
10931100
if ($closure->isPure()->yes() && $returnType->isVoid()->yes()) {
10941101
return new ErrorType();
10951102
}
@@ -1100,6 +1107,36 @@ function (CallableTypeParameterNode $parameterNode) use ($nameScope, &$isVariadi
11001107
return new ErrorType();
11011108
}
11021109

1110+
/**
1111+
* Interprets a conditional return type referencing the callable's own parameter,
1112+
* like `callable(mixed $value): ($value is int ? true : false)`, as a type predicate.
1113+
*
1114+
* @param list<NativeParameterReflection> $parameters
1115+
*/
1116+
private function resolveCallableReturnTypeAssertions(CallableTypeNode $typeNode, NameScope $nameScope, array $parameters): ?Assertions
1117+
{
1118+
$returnTypeNode = $typeNode->returnType;
1119+
if (!$returnTypeNode instanceof ConditionalTypeForParameterNode) {
1120+
return null;
1121+
}
1122+
1123+
foreach ($parameters as $parameter) {
1124+
if ('$' . $parameter->getName() !== $returnTypeNode->parameterName) {
1125+
continue;
1126+
}
1127+
1128+
return CallableAssertionsHelper::createAssertionsFromConditional(
1129+
$returnTypeNode->parameterName,
1130+
$this->resolve($returnTypeNode->targetType, $nameScope),
1131+
$returnTypeNode->negated,
1132+
$this->resolve($returnTypeNode->if, $nameScope),
1133+
$this->resolve($returnTypeNode->else, $nameScope),
1134+
);
1135+
}
1136+
1137+
return null;
1138+
}
1139+
11031140
private function resolveArrayShapeNode(ArrayShapeNode $typeNode, NameScope $nameScope): Type
11041141
{
11051142
$builder = ConstantArrayTypeBuilder::createEmpty();

‎src/Reflection/Assertions.php‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,4 +186,12 @@ public static function createFromResolvedPhpDocBlock(ResolvedPhpDocBlock $phpDoc
186186
return self::create($tags);
187187
}
188188

189+
/**
190+
* @param AssertTag[] $assertTags
191+
*/
192+
public static function createFromAssertTags(array $assertTags): self
193+
{
194+
return self::create($assertTags);
195+
}
196+
189197
}

‎src/Reflection/Callables/FunctionCallableVariant.php‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use PHPStan\Reflection\ExtendedParametersAcceptor;
99
use PHPStan\Reflection\FunctionReflection;
1010
use PHPStan\TrinaryLogic;
11+
use PHPStan\Type\CallableAssertionsHelper;
1112
use PHPStan\Type\Generic\TemplateTypeMap;
1213
use PHPStan\Type\Generic\TemplateTypeVarianceMap;
1314
use PHPStan\Type\NeverType;
@@ -26,6 +27,8 @@ final class FunctionCallableVariant implements CallableParametersAcceptor, Exten
2627
/** @var SimpleImpurePoint[]|null */
2728
private ?array $impurePoints = null;
2829

30+
private ?Assertions $asserts = null;
31+
2932
public function __construct(
3033
private FunctionReflection|ExtendedMethodReflection $function,
3134
private ExtendedParametersAcceptor $variant,
@@ -176,7 +179,10 @@ public function mustUseReturnValue(): TrinaryLogic
176179

177180
public function getAsserts(): Assertions
178181
{
179-
return $this->function->getAsserts();
182+
return $this->asserts ??= CallableAssertionsHelper::withConditionalReturnPredicate(
183+
$this->function->getAsserts(),
184+
$this->variant,
185+
);
180186
}
181187

182188
public function isStaticClosure(): TrinaryLogic

‎src/Reflection/GenericParametersAcceptorResolver.php‎

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,22 @@
22

33
namespace PHPStan\Reflection;
44

5+
use PHPStan\Analyser\OutOfClassScope;
56
use PHPStan\Reflection\Callables\CallableParametersAcceptor;
67
use PHPStan\Reflection\Php\ExtendedDummyParameter;
78
use PHPStan\TrinaryLogic;
9+
use PHPStan\Type\CallableAssertionsHelper;
810
use PHPStan\Type\ConditionalTypeForParameter;
911
use PHPStan\Type\ErrorType;
12+
use PHPStan\Type\Generic\TemplateTypeHelper;
1013
use PHPStan\Type\Generic\TemplateTypeMap;
14+
use PHPStan\Type\Generic\TemplateTypeVariance;
1115
use PHPStan\Type\Generic\TemplateTypeVarianceMap;
1216
use PHPStan\Type\MixedType;
1317
use PHPStan\Type\NeverType;
1418
use PHPStan\Type\Type;
1519
use PHPStan\Type\TypeCombinator;
20+
use PHPStan\Type\UnionType;
1621
use function array_key_exists;
1722
use function array_last;
1823
use function array_map;
@@ -57,6 +62,22 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc
5762
$namedArgTypes[$i] = $argType;
5863
}
5964

65+
// type predicates of passed callables determine their template types exactly,
66+
// so they are inferred first and substituted into the parameter types —
67+
// the remaining template types are then inferred from what is left over,
68+
// e.g. in partition(iterable<T0|T1> $values, callable(T0|T1): ($value is T0 ? true : false) $predicate)
69+
// called with iterable<int|string> and is_int(...), T0 becomes int and T1 string
70+
$predicateTypeMap = TemplateTypeMap::createEmpty();
71+
foreach ($parameters as $param) {
72+
if (!isset($namedArgTypes[$param->getName()])) {
73+
continue;
74+
}
75+
76+
$predicateTypeMap = $predicateTypeMap->union(
77+
self::inferPredicateTemplateTypes($param->getType(), $namedArgTypes[$param->getName()]),
78+
);
79+
}
80+
6081
foreach ($parameters as $param) {
6182
if (isset($namedArgTypes[$param->getName()])) {
6283
$argType = $namedArgTypes[$param->getName()];
@@ -68,18 +89,20 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc
6889
continue;
6990
}
7091

71-
$paramType = $param->getType();
92+
$paramType = self::resolvePredicateTemplateTypes($param->getType(), $predicateTypeMap);
7293
$typeMap = $typeMap->union($paramType->inferTemplateTypes($argType));
7394
$passedArgs['$' . $param->getName()] = $argType;
7495
}
7596

97+
$typeMap = $typeMap->union($predicateTypeMap);
98+
7699
$returnType = $parametersAcceptor->getReturnType();
77100
if (
78101
$returnType instanceof ConditionalTypeForParameter
79102
&& !$returnType->isNegated()
80103
&& array_key_exists($returnType->getParameterName(), $passedArgs)
81104
) {
82-
$paramType = $returnType->getTarget();
105+
$paramType = self::resolvePredicateTemplateTypes($returnType->getTarget(), $predicateTypeMap);
83106
$argType = $passedArgs[$returnType->getParameterName()];
84107
$typeMap = $typeMap->union($paramType->inferTemplateTypes($argType));
85108
}
@@ -142,4 +165,41 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc
142165
return $result;
143166
}
144167

168+
private static function inferPredicateTemplateTypes(Type $paramType, Type $argType): TemplateTypeMap
169+
{
170+
$typeMap = TemplateTypeMap::createEmpty();
171+
if (!$argType->isCallable()->yes()) {
172+
return $typeMap;
173+
}
174+
175+
foreach ($paramType instanceof UnionType ? $paramType->getTypes() : [$paramType] as $innerType) {
176+
if (!$innerType instanceof CallableParametersAcceptor) {
177+
continue;
178+
}
179+
if ($innerType->getAsserts()->getAll() === []) {
180+
continue;
181+
}
182+
183+
foreach ($argType->getCallableParametersAcceptors(new OutOfClassScope()) as $receivedAcceptor) {
184+
$typeMap = $typeMap->union(CallableAssertionsHelper::inferTemplateTypesOnAsserts($innerType, $receivedAcceptor));
185+
}
186+
}
187+
188+
return $typeMap;
189+
}
190+
191+
private static function resolvePredicateTemplateTypes(Type $type, TemplateTypeMap $predicateTypeMap): Type
192+
{
193+
if ($predicateTypeMap->isEmpty()) {
194+
return $type;
195+
}
196+
197+
return TemplateTypeHelper::resolveTemplateTypes(
198+
$type,
199+
$predicateTypeMap,
200+
TemplateTypeVarianceMap::createEmpty(),
201+
TemplateTypeVariance::createInvariant(),
202+
);
203+
}
204+
145205
}

‎src/Reflection/InitializerExprTypeResolver.php‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
use PHPStan\Type\ArrayType;
5757
use PHPStan\Type\BenevolentUnionType;
5858
use PHPStan\Type\BooleanType;
59+
use PHPStan\Type\CallableAssertionsHelper;
5960
use PHPStan\Type\ClassStringType;
6061
use PHPStan\Type\ClosureType;
6162
use PHPStan\Type\Constant\ConstantArrayType;
@@ -965,7 +966,18 @@ public function createFirstClassCallable(
965966
}
966967

967968
$parameters = $variant->getParameters();
968-
$assertions = $function !== null ? $function->getAsserts() : Assertions::createEmpty();
969+
if ($function !== null) {
970+
$assertions = $function->getAsserts();
971+
} elseif ($variant instanceof CallableParametersAcceptor) {
972+
$assertions = $variant->getAsserts();
973+
} else {
974+
$assertions = Assertions::createEmpty();
975+
}
976+
977+
// a conditional return type referencing the function's own parameter,
978+
// like @return ($value is int ? true : false) on is_int(),
979+
// also becomes a type predicate of the resulting Closure
980+
$assertions = CallableAssertionsHelper::withConditionalReturnPredicate($assertions, $variant);
969981
$closureTypes[] = new ClosureType(
970982
$parameters,
971983
$returnType,

0 commit comments

Comments
 (0)