Skip to content

Commit 61b5d3e

Browse files
phpstan-botstaabm
authored andcommitted
Propagate @phpstan-assert annotations to first-class callables and string callables
- Added Assertions support to ClosureType so first-class callables preserve assertion metadata - Modified InitializerExprTypeResolver::createFirstClassCallable() to pass assertions from function/method reflection to the created ClosureType - Added specifyTypesFromCallableCall() in TypeSpecifier to handle variable function calls ($f($v)) by extracting assertions from ClosureType or resolving string callables to their function reflection - New regression test in tests/PHPStan/Analyser/nsrt/bug-14249.php Closes phpstan/phpstan#14249
1 parent 024a65c commit 61b5d3e

File tree

4 files changed

+122
-0
lines changed

4 files changed

+122
-0
lines changed

src/Analyser/TypeSpecifier.php

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
use PHPStan\Type\Accessory\NonEmptyArrayType;
4646
use PHPStan\Type\ArrayType;
4747
use PHPStan\Type\BooleanType;
48+
use PHPStan\Type\ClosureType;
4849
use PHPStan\Type\ConditionalTypeForParameter;
4950
use PHPStan\Type\Constant\ConstantArrayType;
5051
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
@@ -570,6 +571,13 @@ public function specifyTypesInCondition(
570571
}
571572
}
572573

574+
return $this->handleDefaultTruthyOrFalseyContext($context, $expr, $scope);
575+
} elseif ($expr instanceof FuncCall && !($expr->name instanceof Name)) {
576+
$specifiedTypes = $this->specifyTypesFromCallableCall($context, $expr, $scope);
577+
if ($specifiedTypes !== null) {
578+
return $specifiedTypes;
579+
}
580+
573581
return $this->handleDefaultTruthyOrFalseyContext($context, $expr, $scope);
574582
} elseif ($expr instanceof MethodCall && $expr->name instanceof Node\Identifier) {
575583
$methodCalledOnType = $scope->getType($expr->var);
@@ -1764,6 +1772,75 @@ static function (Type $type, callable $traverse) use ($templateTypeMap, &$contai
17641772
return $types;
17651773
}
17661774

1775+
private function specifyTypesFromCallableCall(TypeSpecifierContext $context, FuncCall $call, Scope $scope): ?SpecifiedTypes
1776+
{
1777+
if (!$call->name instanceof Expr) {
1778+
return null;
1779+
}
1780+
1781+
$calleeType = $scope->getType($call->name);
1782+
$args = $call->getArgs();
1783+
1784+
$assertions = null;
1785+
$parametersAcceptor = null;
1786+
1787+
// Check for ClosureType with assertions (from first-class callables)
1788+
if ($calleeType->isCallable()->yes()) {
1789+
foreach ($calleeType->getCallableParametersAcceptors($scope) as $variant) {
1790+
if (!$variant instanceof ClosureType) {
1791+
continue;
1792+
}
1793+
1794+
$variantAssertions = $variant->getAsserts();
1795+
if ($variantAssertions->getAll() === []) {
1796+
continue;
1797+
}
1798+
1799+
$assertions = $variantAssertions;
1800+
$parametersAcceptor = $variant;
1801+
break;
1802+
}
1803+
}
1804+
1805+
// Check for constant string callables (e.g. $f = 'is_positive_int'; $f($v))
1806+
if ($assertions === null) {
1807+
foreach ($calleeType->getConstantStrings() as $constantString) {
1808+
if ($constantString->getValue() === '') {
1809+
continue;
1810+
}
1811+
$functionName = new Name($constantString->getValue());
1812+
if (!$this->reflectionProvider->hasFunction($functionName, $scope)) {
1813+
continue;
1814+
}
1815+
1816+
$functionReflection = $this->reflectionProvider->getFunction($functionName, $scope);
1817+
$functionAssertions = $functionReflection->getAsserts();
1818+
if ($functionAssertions->getAll() === []) {
1819+
continue;
1820+
}
1821+
1822+
$assertions = $functionAssertions;
1823+
if (count($args) > 0) {
1824+
$parametersAcceptor = ParametersAcceptorSelector::selectFromArgs($scope, $args, $functionReflection->getVariants(), $functionReflection->getNamedArgumentsVariants());
1825+
}
1826+
break;
1827+
}
1828+
}
1829+
1830+
if ($assertions === null || $assertions->getAll() === [] || $parametersAcceptor === null) {
1831+
return null;
1832+
}
1833+
1834+
$asserts = $assertions->mapTypes(static fn (Type $type) => TemplateTypeHelper::resolveTemplateTypes(
1835+
$type,
1836+
$parametersAcceptor->getResolvedTemplateTypeMap(),
1837+
$parametersAcceptor instanceof ExtendedParametersAcceptor ? $parametersAcceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(),
1838+
TemplateTypeVariance::createInvariant(),
1839+
));
1840+
1841+
return $this->specifyTypesFromAsserts($context, $call, $asserts, $parametersAcceptor, $scope);
1842+
}
1843+
17671844
/**
17681845
* @return array<string, ConditionalExpressionHolder[]>
17691846
*/

src/Reflection/InitializerExprTypeResolver.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -958,6 +958,7 @@ public function createFirstClassCallable(
958958
}
959959

960960
$parameters = $variant->getParameters();
961+
$assertions = $function !== null ? $function->getAsserts() : Assertions::createEmpty();
961962
$closureTypes[] = new ClosureType(
962963
$parameters,
963964
$returnType,
@@ -970,6 +971,7 @@ public function createFirstClassCallable(
970971
$impurePoints,
971972
acceptsNamedArguments: $acceptsNamedArguments,
972973
mustUseReturnValue: $mustUseReturnValue,
974+
assertions: $assertions,
973975
);
974976
}
975977

src/Type/ClosureType.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode;
1414
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
1515
use PHPStan\PhpDocParser\Printer\Printer;
16+
use PHPStan\Reflection\Assertions;
1617
use PHPStan\Reflection\Callables\CallableParametersAcceptor;
1718
use PHPStan\Reflection\Callables\SimpleImpurePoint;
1819
use PHPStan\Reflection\Callables\SimpleThrowPoint;
@@ -84,6 +85,8 @@ class ClosureType implements TypeWithClassName, CallableParametersAcceptor
8485

8586
private TrinaryLogic $mustUseReturnValue;
8687

88+
private Assertions $assertions;
89+
8790
/**
8891
* @api
8992
* @param list<ParameterReflection>|null $parameters
@@ -107,6 +110,7 @@ public function __construct(
107110
private array $usedVariables = [],
108111
?TrinaryLogic $acceptsNamedArguments = null,
109112
?TrinaryLogic $mustUseReturnValue = null,
113+
?Assertions $assertions = null,
110114
)
111115
{
112116
if ($acceptsNamedArguments === null) {
@@ -126,6 +130,12 @@ public function __construct(
126130
$this->resolvedTemplateTypeMap = $resolvedTemplateTypeMap ?? TemplateTypeMap::createEmpty();
127131
$this->callSiteVarianceMap = $callSiteVarianceMap ?? TemplateTypeVarianceMap::createEmpty();
128132
$this->impurePoints = $impurePoints ?? [new SimpleImpurePoint('functionCall', 'call to an unknown Closure', false)];
133+
$this->assertions = $assertions ?? Assertions::createEmpty();
134+
}
135+
136+
public function getAsserts(): Assertions
137+
{
138+
return $this->assertions;
129139
}
130140

131141
/**
@@ -664,6 +674,7 @@ public function traverse(callable $cb): Type
664674
$this->usedVariables,
665675
$this->acceptsNamedArguments,
666676
$this->mustUseReturnValue,
677+
$this->assertions,
667678
);
668679
}
669680

@@ -715,6 +726,7 @@ public function traverseSimultaneously(Type $right, callable $cb): Type
715726
$this->usedVariables,
716727
$this->acceptsNamedArguments,
717728
$this->mustUseReturnValue,
729+
$this->assertions,
718730
);
719731
}
720732

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php // lint >= 8.1
2+
3+
declare(strict_types = 1);
4+
5+
namespace Bug14249;
6+
7+
use function PHPStan\Testing\assertType;
8+
9+
/**
10+
* @phpstan-assert-if-true positive-int $value
11+
*/
12+
function is_positive_int(mixed $value): bool {
13+
return is_int($value) && $value > 0;
14+
}
15+
16+
function f(mixed $v): void {
17+
$f1 = is_positive_int(...);
18+
$f2 = 'Bug14249\is_positive_int';
19+
20+
if (is_positive_int($v)) {
21+
assertType('int<1, max>', $v);
22+
}
23+
24+
if ($f1($v)) {
25+
assertType('int<1, max>', $v);
26+
}
27+
28+
if ($f2($v)) {
29+
assertType('int<1, max>', $v);
30+
}
31+
}

0 commit comments

Comments
 (0)