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
129 changes: 129 additions & 0 deletions src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
use PHPStan\Node\ExecutionEndNode;
use PHPStan\Node\InvalidateExprNode;
use PHPStan\Node\PropertyAssignNode;
use PHPStan\Parser\ArrayReduceArgVisitor;
use PHPStan\Reflection\Callables\SimpleImpurePoint;
use PHPStan\Reflection\Callables\SimpleThrowPoint;
use PHPStan\Reflection\ExtendedParameterReflection;
Expand All @@ -33,15 +34,18 @@
use PHPStan\Reflection\Native\NativeParameterReflection;
use PHPStan\Reflection\ParameterReflection;
use PHPStan\Reflection\PassedByReference;
use PHPStan\Reflection\Php\DummyParameter;
use PHPStan\ShouldNotHappenException;
use PHPStan\TrinaryLogic;
use PHPStan\Turbo\ShadowedByTurboExtension;
use PHPStan\Type\ClosureType;
use PHPStan\Type\GeneralizePrecision;
use PHPStan\Type\Generic\GenericObjectType;
use PHPStan\Type\Generic\TemplateTypeMap;
use PHPStan\Type\Generic\TemplateTypeVarianceMap;
use PHPStan\Type\IntegerType;
use PHPStan\Type\MixedType;
use PHPStan\Type\NeverType;
use PHPStan\Type\NonAcceptingNeverType;
use PHPStan\Type\NullType;
use PHPStan\Type\Type;
Expand Down Expand Up @@ -150,6 +154,131 @@ public function getClosureType(
);
}

// once array_reduce()'s TReturn is resolved, the callback is typed by the
// carry it resolved to, like the walk of its body that put that carry in
$arrayReduceArgs = $expr->getAttribute(ArrayReduceArgVisitor::ATTRIBUTE_NAME);
if (
$arrayReduceArgs !== null
&& (
$callableParameters === null
|| !isset($callableParameters[0])
|| $callableParameters[0]->getType()->hasTemplateOrLateResolvableType()
)
) {
return $this->resolveArrayReduceCallbackType($scope, $expr, $storage, $arrayReduceArgs, $parameters, $isVariadic, $contextFree);
}

return $this->resolveClosureTypeWithParameters($scope, $expr, $parameters, $isVariadic, $callableParameters, $nativeCallableParameters, $contextFree);
}

/**
* Resolves an array_reduce() callback with a bounded fixed-point iteration
* over its $carry parameter.
*
* The first pass types $carry as the initial argument. When the callback's return
* type escapes it, the carry type is widened and verified in a second pass. When
* even the widened type is not a fixed point, the callback is analysed once more
* with an unknown carry instead of claiming a shape the carry can outgrow.
* See isArrayReduceCarryFixedPoint() for what counts as a fixed point.
*
* @param array{Node\Arg, Node\Arg|null} $arrayReduceArgs
* @param list<NativeParameterReflection> $parameters
*/
private function resolveArrayReduceCallbackType(
MutatingScope $scope,
Node\Expr\Closure|ArrowFunction $expr,
?ExpressionResultStorage $storage,
array $arrayReduceArgs,
array $parameters,
bool $isVariadic,
bool $contextFree,
): ClosureType
{
[$arrayArg, $initialArg] = $arrayReduceArgs;
$itemType = $this->readExprType($storage, $arrayArg->value, $scope, false)->getIterableValueType();
$nativeCallableParameters = self::createArrayReduceCallbackParameters(
new MixedType(),
$this->readExprType($storage, $arrayArg->value, $scope->doNotTreatPhpDocTypesAsCertain(), true)->getIterableValueType(),
);

$carryType = $initialArg !== null ? $this->readExprType($storage, $initialArg->value, $scope, false) : new NullType();
$closureType = $this->resolveClosureTypeWithParameters($scope, $expr, $parameters, $isVariadic, self::createArrayReduceCallbackParameters($carryType, $itemType), $nativeCallableParameters, $contextFree);
if (self::isArrayReduceCarryFixedPoint($carryType, $closureType->getReturnType())) {
return $closureType;
}

$carryType = self::widenArrayReduceCarryType($carryType, $closureType->getReturnType());
$closureType = $this->resolveClosureTypeWithParameters($scope, $expr, $parameters, $isVariadic, self::createArrayReduceCallbackParameters($carryType, $itemType), $nativeCallableParameters, $contextFree);
if (self::isArrayReduceCarryFixedPoint($carryType, $closureType->getReturnType())) {
return $closureType;
}

return $this->resolveClosureTypeWithParameters($scope, $expr, $parameters, $isVariadic, self::createArrayReduceCallbackParameters(new MixedType(), $itemType), $nativeCallableParameters, $contextFree);
}

/**
* Whether the callback returning $returnType keeps the carry within $carryType.
*
* Not isSuperTypeOf(): an array shape without definite sealedness is a supertype
* of the same shape with extra keys, so a carry that grows one key per iteration
* would pass. An implicit never return is the inference giving up on the body,
* not a callback that always throws, so it proves nothing about the carry.
*/
private static function isArrayReduceCarryFixedPoint(Type $carryType, Type $returnType): bool
{
if ($returnType instanceof NeverType && !$returnType->isExplicit()) {
return false;
}

return TypeCombinator::union($carryType, $returnType)->equals($carryType);
}

/**
* @return list<DummyParameter>
*/
private static function createArrayReduceCallbackParameters(Type $carryType, Type $itemType): array
{
return [
new DummyParameter('carry', $carryType, optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null),
new DummyParameter('item', $itemType, optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null),
];
}

/** Widens while preserving constant array shapes so that the second pass can converge. */
private static function widenArrayReduceCarryType(Type $initialType, Type $returnType): Type
{
$union = TypeCombinator::union($initialType, $returnType);
$constantArrays = $union->getConstantArrays();
if (count($constantArrays) > 0) {
$generalized = [];
foreach ($constantArrays as $constantArray) {
$generalized[] = $constantArray->generalizeValues();
}

return TypeCombinator::union(...$generalized);
}

return $union->generalize(GeneralizePrecision::lessSpecific());
}

/**
* Walks the body with the given callable parameters, or answers from the
* per-context cache.
*
* @param list<NativeParameterReflection> $parameters
* @param ParameterReflection[]|null $callableParameters
* @param ParameterReflection[]|null $nativeCallableParameters
*/
private function resolveClosureTypeWithParameters(
MutatingScope $scope,
Node\Expr\Closure|ArrowFunction $expr,
array $parameters,
bool $isVariadic,
?array $callableParameters,
?array $nativeCallableParameters,
bool $contextFree,
): ClosureType
{
$cachedTypes = $this->findCachedTypes($expr);
$cacheKey = $this->closureContextCacheKey($scope, $expr, $callableParameters, $parameters);
if (array_key_exists($cacheKey, $cachedTypes)) {
Expand Down
8 changes: 6 additions & 2 deletions src/Analyser/ScopeOps.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use PHPStan\Node\Printer\ExprPrinter;
use PHPStan\Node\VirtualNode;
use PHPStan\Parser\ArrayMapArgVisitor;
use PHPStan\Parser\ArrayReduceArgVisitor;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Reflection\FunctionReflection;
use PHPStan\Reflection\MethodReflection;
Expand Down Expand Up @@ -71,11 +72,14 @@ public static function nodeKey(Expr $node, ExprPrinter $exprPrinter): string
$attributes = $node->getAttributes();
if (
$node instanceof Node\FunctionLike
&& (($attributes[ArrayMapArgVisitor::ATTRIBUTE_NAME] ?? null) !== null)
&& (
(($attributes[ArrayMapArgVisitor::ATTRIBUTE_NAME] ?? null) !== null)
|| (($attributes[ArrayReduceArgVisitor::ATTRIBUTE_NAME] ?? null) !== null)
)
&& (($attributes['startFilePos'] ?? null) !== null)
) {
$key .= '/*' . $attributes['startFilePos'];
foreach ($attributes[ArrayMapArgVisitor::ATTRIBUTE_NAME] as $arg) {
foreach ($attributes[ArrayMapArgVisitor::ATTRIBUTE_NAME] ?? [] as $arg) {
$key .= ':' . $exprPrinter->printExpr($arg->value);
}
$key .= '*/';
Expand Down
48 changes: 48 additions & 0 deletions src/Parser/ArrayReduceArgVisitor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php declare(strict_types = 1);

namespace PHPStan\Parser;

use Override;
use PhpParser\Node;
use PhpParser\NodeVisitorAbstract;
use PHPStan\DependencyInjection\AutowiredService;
use PHPStan\Turbo\ShadowedByTurboExtension;

#[AutowiredService]
#[ShadowedByTurboExtension(implementation: __DIR__ . '/../../turbo-ext/src/ArrayReduceArgVisitor.cpp')]
final class ArrayReduceArgVisitor extends NodeVisitorAbstract
{

public const ATTRIBUTE_NAME = 'arrayReduceArgs';

#[Override]
public function enterNode(Node $node): ?Node
{
if ($node instanceof Node\Expr\FuncCall && $node->name instanceof Node\Name && !$node->isFirstClassCallable()) {
$functionName = $node->name->toLowerString();
if ($functionName === 'array_reduce') {
$arrayArg = null;
$callbackArg = null;
$initialArg = null;
foreach ($node->getArgs() as $i => $arg) {
if ($arg->unpack) {
return null;
}
$name = $arg->name !== null ? $arg->name->toString() : null;
if ($name === 'array' || ($name === null && $i === 0)) {
$arrayArg = $arg;
} elseif ($name === 'callback' || ($name === null && $i === 1)) {
$callbackArg = $arg;
} elseif ($name === 'initial' || ($name === null && $i === 2)) {
$initialArg = $arg;
}
}
if ($arrayArg !== null && $callbackArg !== null) {
$callbackArg->value->setAttribute(self::ATTRIBUTE_NAME, [$arrayArg, $initialArg]);
}
}
}
return null;
}

}
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 = '41f4d63';

private static bool $active = false;

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

namespace PHPStan\Analyser;

use PHPStan\Testing\TypeInferenceTestCase;
use PHPUnit\Framework\Attributes\DataProvider;

/**
* Without bleeding edge, array shapes have no definite sealedness, and
* array{} is a supertype of array{int} - a growing array_reduce() carry must
* not be mistaken for a fixed point there.
*/
class Bug7280Test extends TypeInferenceTestCase
{

public static function dataFileAsserts(): iterable
{
yield from self::gatherAssertTypes(__DIR__ . '/data/bug-7280-unsealed.php');
}

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

}
41 changes: 41 additions & 0 deletions tests/PHPStan/Analyser/data/bug-7280-unsealed.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

namespace Bug7280Unsealed;

use function PHPStan\Testing\assertType;

/**
* @param list<int> $integers
*/
function listAppend(array $integers): void
{
// the carry grows by one element per item - no array shape is a fixed point
$result = array_reduce($integers, function ($carry, $value) {
$carry[] = $value;

return $carry;
}, []);
assertType('mixed', $result);

$typed = array_reduce($integers, function (array $carry, int $value): array {
assertType('array', $carry);
$carry[] = $value;

return $carry;
}, []);
assertType('array', $typed);
}

/**
* @param list<string> $strings
*/
function doubleAppend(array $strings): void
{
$result = array_reduce($strings, function (array $carry, string $value): array {
$carry[] = $value;
$carry[] = $value;

return $carry;
}, []);
assertType('array', $result);
}
Loading
Loading