Skip to content
Open
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
83 changes: 74 additions & 9 deletions src/Analyser/ArgumentsHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
use function array_values;
use function count;
use function get_class;
use function is_array;
use function is_string;
use function spl_object_id;
use function sprintf;
Expand Down Expand Up @@ -1001,11 +1002,13 @@ private function gatherClosureArgType(array $parametersAcceptors, int $i, Expr $
* built without walking anything: a nested array literal recurses, a closure /
* arrow function contributes its DECLARED signature
* (ClosureTypeResolver::getDeclaredClosureType()), and every other key/value
* is priced by the scope state it is already tracked as, falling back to the
* is priced by the scope state it is already tracked as, while preceding
* elements have not changed that state, falling back to the
* constant-expression resolver (literals, ::class, constants, concatenation)
* and ultimately to mixed. Widening a slot to mixed is safe: a template is
* never resolved below its bound, so the skeleton can only ever sharpen the
* resolution.
* and ultimately to mixed. Scope-known method callables retain their variants;
* expressions that require a walk are mixed. Widening a slot to mixed is
* safe: a template is never resolved below its bound, so the skeleton can
* only ever sharpen the resolution.
*
* Unlike gatherClosureArgType() this type never reaches $gatheredTypes: it
* exists solely to resolve the parameter type the nested closures are typed
Expand All @@ -1014,20 +1017,82 @@ private function gatherClosureArgType(array $parametersAcceptors, int $i, Expr $
private function gatherArrayArgTypeSkeleton(NodeScopeResolver $nodeScopeResolver, Expr\Array_ $expr, MutatingScope $scope): Type
{
$initializerContext = InitializerExprContext::fromScope($scope);
$getType = function (Expr $inner) use (&$getType, $nodeScopeResolver, $scope, $initializerContext): Type {
$changesScope = static function (Node $node) use (&$changesScope): bool {
if ($node instanceof Expr\Closure || $node instanceof Expr\ArrowFunction) {
return false;
}
if (
($node instanceof CallLike && !$node->isFirstClassCallable())
|| $node instanceof Expr\Assign || $node instanceof Expr\AssignRef || $node instanceof Expr\AssignOp
|| $node instanceof Expr\PreInc || $node instanceof Expr\PreDec || $node instanceof Expr\PostInc || $node instanceof Expr\PostDec
|| $node instanceof Expr\Yield_ || $node instanceof Expr\YieldFrom || $node instanceof Expr\Include_ || $node instanceof Expr\Eval_
) {
return true;
}
foreach ($node->getSubNodeNames() as $name) {
$children = $node->{$name};
foreach (is_array($children) ? $children : [$children] as $child) {
if ($child instanceof Node && $changesScope($child)) {
return true;
}
}
}
return false;
};
$types = [];
$scopeIsValid = true;
$getType = function (Expr $inner) use (&$getType, &$types, &$scopeIsValid, $changesScope, $nodeScopeResolver, $scope, $initializerContext): Type {
if ($inner instanceof Expr\Closure || $inner instanceof Expr\ArrowFunction) {
return $this->closureTypeResolver->getDeclaredClosureType($scope, $inner);
}

if ($inner instanceof Expr\Array_) {
return $this->initializerExprTypeResolver->getArrayType($inner, $getType);
// Price keys before values, as in the real walk. The array builder
// requests values first, so give it the already computed types.
foreach ($inner->items as $item) {
if ($item->key !== null) {
$types[spl_object_id($item->key)] = $getType($item->key);
}
$types[spl_object_id($item->value)] = $getType($item->value);
if (!$item->byRef) {
continue;
}

$scopeIsValid = false;
}
return $this->initializerExprTypeResolver->getArrayType($inner, static fn (Expr $item): Type => $types[spl_object_id($item)]);
}

return $nodeScopeResolver->findScopeStateType($inner, $scope)
?? $this->initializerExprTypeResolver->getType($inner, $initializerContext);
if ($changesScope($inner)) {
$scopeIsValid = false;
return new MixedType();
}
if ($scopeIsValid) {
$stateType = $nodeScopeResolver->findScopeStateType($inner, $scope);
if ($stateType !== null) {
return $stateType;
}
if ($inner instanceof MethodCall && $inner->isFirstClassCallable() && $inner->name instanceof Identifier) {
$receiverType = $nodeScopeResolver->findScopeStateType($inner->var, $scope);
if ($receiverType !== null) {
$method = $scope->getMethodReflection($receiverType, $inner->name->toString());
if ($method !== null) {
return $this->initializerExprTypeResolver->createFirstClassCallable($method, $method->getVariants(), $scope->nativeTypesPromoted);
}
}
}
}
if (
!($inner instanceof FuncCall && $inner->name instanceof Name)
&& !($inner instanceof StaticCall && $inner->class instanceof Name && $inner->name instanceof Identifier)
&& (new NodeFinder())->findFirst($inner, static fn (Node $node): bool => $node instanceof CallLike) !== null
) {
return new MixedType();
}
return $this->initializerExprTypeResolver->getType($inner, $initializerContext);
};

return $this->initializerExprTypeResolver->getArrayType($expr, $getType);
return $getType($expr);
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/Turbo/TurboExtensionEnabler.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
final class TurboExtensionEnabler
{

public const EXPECTED_EXTENSION_VERSION = '28c29b8';
public const EXPECTED_EXTENSION_VERSION = 'f267b22';

private static bool $active = false;

Expand Down
32 changes: 32 additions & 0 deletions tests/PHPStan/Analyser/ArrayArgumentSkeletonTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php declare(strict_types = 1);

namespace PHPStan\Analyser;

use PHPStan\Testing\TypeInferenceTestCase;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\RequiresPhp;
use const PHP_VERSION_ID;

class ArrayArgumentSkeletonTest extends TypeInferenceTestCase
{

public static function dataAsserts(): iterable
{
if (PHP_VERSION_ID < 80100) {
return;
}
yield from self::gatherAssertTypes(__DIR__ . '/nsrt/bug-15432-callable.php');
yield from self::gatherAssertTypes(__DIR__ . '/nsrt/bug-15432-array-effects.php');
}

/**
* @param mixed ...$args
*/
#[DataProvider('dataAsserts')]
#[RequiresPhp('>= 8.1.0')]
public function testAsserts(string $assertType, string $file, ...$args): void
{
$this->assertFileAsserts($assertType, $file, ...$args);
}

}
104 changes: 104 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-15432-array-effects.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php // lint >= 8.1

namespace Bug15432ArrayEffects;

use function PHPStan\Testing\assertType;

/**
* @template T
* @param array{first: mixed, value: T, callback: callable(T): void} $spec
*/
function run(array $spec): void {}

function increment(): void
{
$i = 0;
run([
'first' => $i++,
'value' => $i,
'callback' => function ($value): void { assertType('mixed', $value); },
]);
}

function assignment(): void
{
$value = 1;
run([
'first' => $value = 'str',
'value' => $value,
'callback' => function ($value): void { assertType('mixed', $value); },
]);
}

function nested(): void
{
$i = 0;
run([
'first' => [$i++],
'value' => $i,
'callback' => function ($value): void { assertType('mixed', $value); },
]);
}

function mutate(int &$value): void { $value++; }

function byReferenceArgument(): void
{
$i = 0;
run([
'first' => mutate($i),
'value' => $i,
'callback' => function ($value): void { assertType('mixed', $value); },
]);
}

function keyEffect(): void
{
$i = 0;
run([
($i++ === 0 ? 'first' : 'first') => $i,
'value' => $i,
'callback' => function ($value): void { assertType('mixed', $value); },
]);
}

function literalAfterEffect(): void
{
$i = 0;
run([
'first' => $i++,
'value' => 42,
'callback' => function ($value): void { assertType('42', $value); },
]);
}

class Impure
{

public int $prop = 5;

public function reset(): void { $this->prop = 7; }

public function test(): void
{
if ($this->prop !== 5) {
return;
}
run([
'first' => $this->reset(),
'value' => $this->prop,
'callback' => function ($value): void { assertType('mixed', $value); },
]);
}

}

function deferredBody(): void
{
$i = 0;
run([
'first' => function () use (&$i): void { $i++; },
'value' => $i,
'callback' => function ($value): void { assertType('0', $value); },
]);
}
52 changes: 52 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-15432-callable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php // lint >= 8.1

namespace Bug15432Callable;

use function PHPStan\Testing\assertType;

/**
* @template T
* @param array{producer: callable(): T, consumer: callable(T): void} $pair
*/
function pipe(array $pair): void {}

class Producer
{

public function produce(): int { return 1; }
public static function produceStatic(): int { return 1; }

}

function test(Producer $producer): void
{
pipe([
'producer' => $producer->produce(...),
'consumer' => function ($value): void { assertType('int', $value); },
]);
$callable = $producer->produce(...);
pipe([
'producer' => $callable,
'consumer' => function ($value): void { assertType('int', $value); },
]);
pipe([
'producer' => Producer::produceStatic(...),
'consumer' => function ($value): void { assertType('int', $value); },
]);
}

function dynamicName(Producer $producer, string $name): void
{
pipe([
'producer' => $producer->{$name}(...),
'consumer' => function ($value): void { assertType('mixed', $value); },
]);
}

function dynamicStatic(string $className, string $methodName): void
{
pipe([
'producer' => $className::{$methodName}(...),
'consumer' => function ($value): void { assertType('mixed', $value); },
]);
}
15 changes: 15 additions & 0 deletions tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1230,6 +1230,21 @@ public function testFirstClassCallables(): void
$this->analyse([__DIR__ . '/data/first-class-callables.php'], []);
}

public function testBug15432(): void
{
$this->analyse([__DIR__ . '/data/bug-15432.php'], []);
}

public function testBug15432Invalid(): void
{
$this->analyse([__DIR__ . '/data/bug-15432-invalid.php'], [
[
'Parameter #1 $callbacks of function Bug15432Invalid\\consume expects array<\'closure\'|\'method\', callable(int): string>, array{method: Closure(string): int, closure: Closure(int): decimal-int-string} given.',
23,
],
]);
}

public function testBug4413(): void
{
require_once __DIR__ . '/data/bug-4413.php';
Expand Down
27 changes: 27 additions & 0 deletions tests/PHPStan/Rules/Functions/data/bug-15432-invalid.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php // lint >= 8.1

namespace Bug15432Invalid;

/**
* @template T of string
* @param array<T, callable(int): string> $callbacks
*/
function consume(array $callbacks): void
{
}

class Handler
{
public function handle(string $value): int
{
return strlen($value);
}
}

function invalid(Handler $handler): void
{
consume([
'method' => $handler->handle(...),
'closure' => static fn (int $value): string => (string) $value,
]);
}
Loading
Loading