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
45 changes: 44 additions & 1 deletion src/Analyser/ExprHandler/ArrayHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
use PhpParser\Node\Arg;
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\Array_;
use PhpParser\Node\Expr\ArrowFunction;
use PhpParser\Node\Expr\Closure;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Name\FullyQualified;
use PhpParser\Node\Stmt;
Expand All @@ -25,7 +27,11 @@
use PHPStan\Node\Variable\VariableWrite;
use PHPStan\Reflection\InitializerExprTypeResolver;
use PHPStan\ShouldNotHappenException;
use PHPStan\Type\ArrayType;
use PHPStan\Type\CallableType;
use PHPStan\Type\Constant\ConstantIntegerType;
use PHPStan\Type\IntegerType;
use PHPStan\Type\MixedType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use function array_key_exists;
Expand Down Expand Up @@ -68,6 +74,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
if ($literalWrite !== null && $literalWrite->isOffsetWrite()) {
$literalWrite = null;
}
$passedToType = $this->getExpectedArrayType($context->getPassedToType());
$nativePassedToType = $this->getExpectedArrayType($context->getNativePassedToType());
$hasExpectedType = $passedToType !== null || $nativePassedToType !== null;
$nextIndex = 0;
foreach ($expr->items as $arrayItem) {
$itemNodes[] = new LiteralArrayItem($scope, $arrayItem);
Expand All @@ -85,7 +94,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
}

$valueContext = $context->enterDeepKeepingValueFlow();
if ($literalWrite !== null) {
$keyType = null;
if ($hasExpectedType && !$arrayItem->unpack && ($arrayItem->value instanceof Array_ || $arrayItem->value instanceof Closure || $arrayItem->value instanceof ArrowFunction)) {
$keyType = $keyResult !== null ? $keyResult->getType()->toArrayKey() : ($nextIndex !== null ? new ConstantIntegerType($nextIndex) : new IntegerType());
}
if ($literalWrite !== null || $hasExpectedType) {
if ($arrayItem->unpack) {
$offset = null;
$nextIndex = null;
Expand All @@ -102,10 +115,18 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
$nextIndex = max($nextIndex, $offset + 1);
}
}
}
if ($literalWrite !== null) {
$itemWrite = new VariableWrite($literalWrite->getVariableName(), $arrayItem, spl_object_id($arrayItem), VariableWrite::KIND_ARRAY_LITERAL_ITEM, true, $offset, $literalWrite->getId());
$variableFlows[] = VariableFlow::write($itemWrite);
$valueContext = $context->enterDeep()->enterValueFlow($itemWrite, false);
}
if ($keyType !== null) {
$valueContext = $valueContext->enterPassedToType(
$this->getExpectedValueType($passedToType, $keyType),
$this->getExpectedValueType($nativePassedToType, $keyType),
);
}
$valueResult = $nodeScopeResolver->processExprNode($stmt, $arrayItem->value, $scope, $storage, $nodeCallback, $valueContext);
$itemResults[spl_object_id($arrayItem->value)] = $valueResult;
$variableFlows[] = $valueResult->getVariableFlow();
Expand Down Expand Up @@ -174,4 +195,26 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
);
}

private function getExpectedArrayType(?Type $type): ?Type
{
if ($type === null || $type->isIterable()->no()) {
return null;
}

if ($type->isArray()->yes()) {
return $type;
}

return TypeCombinator::intersect($type, new ArrayType(new MixedType(), new MixedType()));
}

private function getExpectedValueType(?Type $arrayType, Type $keyType): ?Type
{
if ($arrayType === null || $arrayType->hasOffsetValueType($keyType)->no()) {
return null;
}

return $arrayType->getOffsetValueType($keyType);
}

}
6 changes: 5 additions & 1 deletion src/Analyser/ExprHandler/ArrowFunctionHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public function supports(Expr $expr): bool

public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult
{
$arrowFunctionResult = $nodeScopeResolver->processArrowFunctionNode($stmt, $expr, $scope, $storage, $nodeCallback, null, null, $context);
$arrowFunctionResult = $nodeScopeResolver->processArrowFunctionNode($stmt, $expr, $scope, $storage, $nodeCallback, $context->getPassedToType(), $context->getNativePassedToType(), $context);
$result = $arrowFunctionResult->getExpressionResult();

// A plain typeCallback recursing through getClosureType() would re-walk
Expand All @@ -63,6 +63,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
$arrowFunctionResult->getInvalidateExpressions(),
false,
$storage,
$context->getPassedToType(),
$context->getNativePassedToType(),
);
$nativeType = $this->closureTypeResolver->buildClosureTypeForArrowFunction(
$scope,
Expand All @@ -73,6 +75,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
$arrowFunctionResult->getInvalidateExpressions(),
true,
$storage,
$context->getPassedToType(),
$context->getNativePassedToType(),
);

return $this->expressionResultFactory->create(
Expand Down
4 changes: 3 additions & 1 deletion src/Analyser/ExprHandler/ClosureHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public function supports(Expr $expr): bool

public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult
{
$processClosureResult = $nodeScopeResolver->processClosureNode($stmt, $expr, $scope, $storage, $nodeCallback, $context, null);
$processClosureResult = $nodeScopeResolver->processClosureNode($stmt, $expr, $scope, $storage, $nodeCallback, $context, $context->getPassedToType(), $context->getNativePassedToType());

// A plain typeCallback recursing through getClosureType() would re-walk
// the body each getType() ask before the cache populates and hang;
Expand All @@ -67,6 +67,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
$processClosureResult->getInvalidateExpressions(),
false,
$storage,
$context->getPassedToType(),
$context->getNativePassedToType(),
);
$nativeType = $type;

Expand Down
15 changes: 9 additions & 6 deletions src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -308,9 +308,11 @@ public function buildClosureTypeForClosure(
array $invalidateExpressions,
bool $native = false,
?ExpressionResultStorage $storage = null,
?Type $passedToType = null,
?Type $nativePassedToType = null,
): ClosureType
{
[$parameters, $isVariadic, $callableParameters, $nativeCallableParameters] = $this->buildParametersAndAcceptors($scope, $expr, $storage);
[$parameters, $isVariadic, $callableParameters, $nativeCallableParameters] = $this->buildParametersAndAcceptors($scope, $expr, $storage, $passedToType, $nativePassedToType);

return $this->buildClosureTypeFromClosureWalk(
$scope,
Expand Down Expand Up @@ -356,9 +358,11 @@ public function buildClosureTypeForArrowFunction(
array $invalidateExpressions,
bool $native = false,
?ExpressionResultStorage $storage = null,
?Type $passedToType = null,
?Type $nativePassedToType = null,
): ClosureType
{
[$parameters, $isVariadic, $callableParameters, $nativeCallableParameters] = $this->buildParametersAndAcceptors($scope, $expr, $storage);
[$parameters, $isVariadic, $callableParameters, $nativeCallableParameters] = $this->buildParametersAndAcceptors($scope, $expr, $storage, $passedToType, $nativePassedToType);

$returnType = $this->resolveArrowFunctionReturnType($scope, $arrowScope, $expr, $native, $storage);

Expand Down Expand Up @@ -784,14 +788,13 @@ private function buildParametersAndAcceptors(
MutatingScope $scope,
Node\Expr\Closure|ArrowFunction $expr,
?ExpressionResultStorage $storage = null,
?Type $passedToType = null,
?Type $nativePassedToType = null,
): array
{
[$parameters, $isVariadic] = $this->buildDeclaredParameters($scope, $expr);

$passedToType = null;
$nativePassedToType = null;
$inFunctionCallsStackCount = count($scope->inFunctionCallsStack);
if ($inFunctionCallsStackCount > 0) {
if ($passedToType === null && $inFunctionCallsStackCount > 0) {
[, $inParameter] = $scope->inFunctionCallsStack[$inFunctionCallsStackCount - 1];
if ($inParameter !== null) {
$passedToType = $inParameter->getType();
Expand Down
30 changes: 28 additions & 2 deletions src/Analyser/ExpressionContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ private function __construct(
private bool $arrayDimFetchRoot = false,
private bool $unsetTarget = false,
private ?bool $valueConsumed = null,
private ?Type $passedToType = null,
private ?Type $nativePassedToType = null,
)
{
}
Expand All @@ -46,7 +48,7 @@ public static function createDeep(bool $resolveTemplateArguments = true): self
*/
public function enterDeep(): self
{
if ($this->isDeep && $this->valueFlowTarget === null && !$this->arrayDimFetchRoot && !$this->unsetTarget) {
if ($this->isDeep && $this->valueFlowTarget === null && !$this->arrayDimFetchRoot && !$this->unsetTarget && $this->passedToType === null && $this->nativePassedToType === null) {
return $this;
}

Expand Down Expand Up @@ -74,7 +76,7 @@ public function enterDeepKeepingValueFlow(): self
*/
public function withoutValueFlow(): self
{
if ($this->valueFlowTarget === null && !$this->arrayDimFetchRoot && !$this->unsetTarget) {
if ($this->valueFlowTarget === null && !$this->arrayDimFetchRoot && !$this->unsetTarget && $this->passedToType === null && $this->nativePassedToType === null) {
return $this;
}

Expand All @@ -92,6 +94,30 @@ public function isValueConsumed(): bool
return $this->valueFlowTarget !== null || ($this->valueConsumed ?? $this->isDeep);
}

/** Applies only to this expression; child expressions must receive their own expected types. */
public function enterPassedToType(?Type $type, ?Type $nativeType): self
{
if ($this->passedToType === $type && $this->nativePassedToType === $nativeType) {
return $this;
}

$context = clone $this;
$context->passedToType = $type;
$context->nativePassedToType = $nativeType;

return $context;
}

public function getPassedToType(): ?Type
{
return $this->passedToType;
}

public function getNativePassedToType(): ?Type
{
return $this->nativePassedToType;
}

public function isDeep(): bool
{
return $this->isDeep;
Expand Down
6 changes: 5 additions & 1 deletion src/Analyser/NodeScopeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -3143,7 +3143,11 @@ public function processArgs(
if ($enterExpressionAssignForByRef) {
$scopeToPass = $scopeToPass->enterExpressionAssign($arg->value);
}
$exprResult = $this->processExprNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $context->enterDeep());
$argContext = $context->enterDeep();
if (!$arg->unpack && $arg->value instanceof Expr\Array_) {
$argContext = $argContext->enterPassedToType($parameterType, $parameterNativeType);
}
$exprResult = $this->processExprNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $argContext);
$argResults[spl_object_id($arg->value)] = $exprResult;
$exprType = $exprResult->getType();
$throwPoints = array_merge($throwPoints, $exprResult->getThrowPoints());
Expand Down
98 changes: 98 additions & 0 deletions tests/PHPStan/Analyser/nsrt/array-closure-parameters.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php // lint >= 8.0

declare(strict_types = 1);

namespace ArrayClosureParameters;

use Closure;
use function PHPStan\Testing\assertNativeType;
use function PHPStan\Testing\assertType;

/** @param list<callable(string, int): mixed> $callbacks */
function callbacks(array $callbacks): void {}

callbacks([
function ($value, $key) {
assertType('string', $value);
assertType('int', $key);
assertNativeType('mixed', $value);
assertNativeType('mixed', $key);
return $value;
},
fn ($value, $key) => [assertType('string', $value), assertType('int', $key), assertNativeType('mixed', $value)],
function (int $value, $key) {
assertType('int', $value);
assertType('int', $key);
},
]);

/** @param array{first: callable(string): mixed, second?: Closure(int): mixed} $callbacks */
function shape(array $callbacks): void {}

shape(callbacks: [
'second' => fn ($value) => assertType('int', $value),
'first' => function ($value) {
assertType('string', $value);
$unrelated = [function ($other) {
assertType('mixed', $other);
}];
},
]);

/** @param array{callable(string): mixed, callable(int): mixed} $callbacks */
function tuple(array $callbacks): void {}

tuple([
fn ($value) => assertType('string', $value),
fn ($value) => assertType('int', $value),
]);

/** @param array{5: string, 6: callable(int): mixed} $callbacks */
function numericShape(array $callbacks): void {}

numericShape([5 => 'value', fn ($value) => assertType('int', $value)]);

/** @param array<string, list<callable(string): mixed>> $callbacks */
function nested(array $callbacks): void {}

nested(['first' => [function ($value) {
assertType('string', $value);
}]]);

/** @param list<callable(string): mixed>|null $callbacks */
function nullable(?array $callbacks): void {}

nullable([fn ($value) => assertType('string', $value)]);

/** @param iterable<callable(string): mixed> $callbacks */
function iterableCallbacks(iterable $callbacks): void {}

iterableCallbacks([fn ($value) => assertType('string', $value)]);

/**
* @template T
* @param T $value
* @param list<callable(T): mixed> $callbacks
*/
function generic($value, array $callbacks): void {}

generic(new \stdClass(), [fn ($value) => assertType('stdClass', $value)]);

class Receiver
{
/** @param list<callable(string): mixed> $callbacks */
public function __construct(array $callbacks) {}

/** @param list<callable(int): mixed> $callbacks */
public static function run(array $callbacks): void {}

/** @param list<callable(string): mixed> ...$callbacks */
public function variadic(array ...$callbacks): void {}
}

$receiver = new Receiver([fn ($value) => assertType('string', $value)]);
Receiver::run([fn ($value) => assertType('int', $value)]);
$receiver->variadic([fn ($value) => assertType('string', $value)], [fn ($value) => assertType('string', $value)]);

$unrelated = [fn ($value) => assertType('mixed', $value)];
callbacks($unrelated);
39 changes: 39 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-11215.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php declare(strict_types = 1);

namespace Bug11215;

use function PHPStan\Testing\assertType;

class User {}

/** @template TModel */
class Builder
{
}

/** @template TModel */
class Collection
{

/** @param callable(Builder<TModel>): mixed $relation */
public function load($relation): void
{
//
}

/** @param array<string, (callable(Builder<TModel>): mixed)|string> $relations */
public function loadMany($relations): void
{
//
}

}

/** @var Collection<User> $users */
$users->load(function ($query) {
assertType('Bug11215\Builder<Bug11215\User>', $query);
});

$users->loadMany(['foo' => function ($query) {
assertType('Bug11215\Builder<Bug11215\User>', $query);
}]);
Loading
Loading