Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6787a50
Add `TypeTraverser::mapMemoized()` and compare shared array shape off…
staabm Oct 1, 2026
a4bd7b1
Test that a repeated missing iterable value type is reported once reg…
phpstan-bot Oct 2, 2026
3b58c9d
bug number the duplicate error problem
staabm Oct 2, 2026
2dd83cc
free the memo after traversal
staabm Oct 2, 2026
e5f1db7
revert hallucination
staabm Oct 2, 2026
c12cb48
Revert "revert hallucination"
staabm Oct 2, 2026
3decdf4
Test that a repeated non-generic object type of a generic class is re…
phpstan-bot Oct 2, 2026
25fd8b5
Collect only by key or by flag in TypeTraverser::mapMemoized() callbacks
phpstan-bot Oct 2, 2026
8aa5634
Use TypeTraverser::mapMemoized() for callbacks independent of where a…
phpstan-bot Oct 2, 2026
113e80b
Add a rule choosing between TypeTraverser::map() and mapMemoized() by…
phpstan-bot Oct 2, 2026
5b95472
Hook TypeTraverserMapMemoizedRule on StaticCall
phpstan-bot Oct 2, 2026
f4eaf3f
Keep TypeTraverser::map() in GenericObjectTypeCheck::getGenericTypes()
phpstan-bot Oct 2, 2026
3a769e8
Leave callbacks collecting by spl_object_id() to TypeTraverser::map()
phpstan-bot Oct 2, 2026
fc1a238
Make TypeTraverserMapMemoizedRule a regular rule
phpstan-bot Oct 2, 2026
9374711
Drop the tests/ ignore for TypeTraverserMapMemoizedRule
phpstan-bot Oct 2, 2026
d81481c
Drop auto-fixing from TypeTraverserMapMemoizedRule
phpstan-bot Oct 2, 2026
82cee5b
Revert the ConstantArrayType::checkOurKeys() accepts() cache
phpstan-bot Oct 3, 2026
84388f6
Remove TypeTraverserMapMemoizedRule
phpstan-bot Oct 5, 2026
eb8f2d2
Use TypeTraverser::mapMemoized() only on the hot paths of big array s…
phpstan-bot Oct 5, 2026
da93e58
Revert merging template errors of all callables in GenericCallableRul…
phpstan-bot Oct 5, 2026
8435521
Revert LocalTypeAliasesCheck::hasErrorType() to TypeTraverser::map()
phpstan-bot Oct 5, 2026
b13d051
Revert the lazy verbosity in ObjectShapeType::accepts()
phpstan-bot Oct 5, 2026
6f1568e
Bump expected turbo version
staabm Oct 5, 2026
daff12b
cleanup
staabm Oct 5, 2026
459355c
Bump expected turbo version
staabm Oct 5, 2026
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
2 changes: 1 addition & 1 deletion src/Analyser/MutatingScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -2199,7 +2199,7 @@ public function enterPropertyHook(

private function transformStaticType(Type $type): Type
{
return TypeTraverser::map($type, new TransformStaticTypeTraverser($this));
return TypeTraverser::mapMemoized($type, new TransformStaticTypeTraverser($this));
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/Analyser/PhpDocsResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ private function getPhpDocReturnType(ResolvedPhpDocBlock $resolvedPhpDoc, Type $

private function transformStaticType(ClassReflection $declaringClass, Type $type): Type
{
return TypeTraverser::map($type, static function (Type $type, callable $traverse) use ($declaringClass): Type {
return TypeTraverser::mapMemoized($type, static function (Type $type, callable $traverse) use ($declaringClass): Type {
if ($type instanceof StaticType) {
$changedType = $type->changeBaseClass($declaringClass);
if ($declaringClass->isFinal() && !$type instanceof ThisType) {
Expand Down
5 changes: 3 additions & 2 deletions src/Parser/TypeTraverserInstanceofVisitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use PhpParser\NodeVisitorAbstract;
use PHPStan\DependencyInjection\AutowiredService;
use PHPStan\Turbo\ShadowedByTurboExtension;
use function in_array;

#[AutowiredService]
#[ShadowedByTurboExtension(implementation: __DIR__ . '/../../turbo-ext/src/TypeTraverserInstanceofVisitor.cpp')]
Expand Down Expand Up @@ -37,7 +38,7 @@ public function enterNode(Node $node): ?Node
&& $node->class instanceof Node\Name
&& $node->class->toLowerString() === 'phpstan\\type\\typetraverser'
&& $node->name instanceof Node\Identifier
&& $node->name->toLowerString() === 'map'
&& in_array($node->name->toLowerString(), ['map', 'mapmemoized'], true)
) {
$this->depth++;
}
Expand All @@ -53,7 +54,7 @@ public function leaveNode(Node $node): ?Node
&& $node->class instanceof Node\Name
&& $node->class->toLowerString() === 'phpstan\\type\\typetraverser'
&& $node->name instanceof Node\Identifier
&& $node->name->toLowerString() === 'map'
&& in_array($node->name->toLowerString(), ['map', 'mapmemoized'], true)
) {
$this->depth--;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ private function transformMethodWithStaticType(ClassReflection $declaringClass,

private function transformStaticType(Type $type): Type
{
return TypeTraverser::map($type, function (Type $type, callable $traverse): Type {
return TypeTraverser::mapMemoized($type, function (Type $type, callable $traverse): Type {
if ($type instanceof GenericStaticType) {
$calledOnTypeReflections = $this->calledOnType->getObjectClassReflections();
if (count($calledOnTypeReflections) === 1) {
Expand Down
26 changes: 14 additions & 12 deletions src/Rules/MissingTypehintCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
use function array_filter;
use function array_keys;
use function array_merge;
use function array_values;
use function count;
use function implode;
use function in_array;
Expand Down Expand Up @@ -73,7 +74,7 @@ public function __construct(
public function getIterableTypesWithMissingValueTypehint(Type $type): array
{
$descriptions = [];
TypeTraverser::map($type, function (Type $type, callable $traverse) use (&$descriptions): Type {
TypeTraverser::mapMemoized($type, function (Type $type, callable $traverse) use (&$descriptions): Type {
if ($type instanceof TemplateType) {
return $type;
}
Expand All @@ -98,17 +99,19 @@ public function getIterableTypesWithMissingValueTypehint(Type $type): array
return $traverse(new IntersectionType($nonArrayInner));
}
if ($type instanceof ConditionalType || $type instanceof ConditionalTypeForParameter) {
$descriptions = array_merge(
$descriptions,
$branchDescriptions = array_merge(
$this->getIterableTypesWithMissingValueTypehint($type->getIf()),
$this->getIterableTypesWithMissingValueTypehint($type->getElse()),
);
foreach ($branchDescriptions as $description) {
$descriptions[$description] = $description;
}

return $type;
}
if ($type->isIterable()->yes()) {
if ($type->isConstantArray()->yes()) {
$type = TypeTraverser::map($type, static function (Type $type, callable $traverse) {
$type = TypeTraverser::mapMemoized($type, static function (Type $type, callable $traverse) {
if ($type instanceof UnionType || $type instanceof IntersectionType) {
return $traverse($type);
}
Expand All @@ -125,7 +128,8 @@ public function getIterableTypesWithMissingValueTypehint(Type $type): array
}
$iterableValue = $type->getIterableValueType();
if ($iterableValue instanceof MixedType && !$iterableValue->isExplicitMixed()) {
$descriptions[] = sprintf('iterable type %s', $type->describe(VerbosityLevel::typeOnly()));
$description = sprintf('iterable type %s', $type->describe(VerbosityLevel::typeOnly()));
$descriptions[$description] = $description;
}
if ($type instanceof IntersectionType) {
if ($type->isList()->yes()) {
Expand All @@ -138,7 +142,7 @@ public function getIterableTypesWithMissingValueTypehint(Type $type): array
return $traverse($type);
});

return $descriptions;
return array_values($descriptions);
}

/**
Expand All @@ -147,7 +151,7 @@ public function getIterableTypesWithMissingValueTypehint(Type $type): array
public function getNonGenericObjectTypesWithGenericClass(Type $type): array
{
$objectTypes = [];
TypeTraverser::map($type, function (Type $type, callable $traverse) use (&$objectTypes): Type {
TypeTraverser::mapMemoized($type, function (Type $type, callable $traverse) use (&$objectTypes): Type {
if ($type instanceof GenericObjectType || $type instanceof GenericStaticType) {
$traverse($type);
return $type;
Expand Down Expand Up @@ -197,17 +201,15 @@ public function getNonGenericObjectTypesWithGenericClass(Type $type): array
$templateTypesList .= sprintf(' (%d-%d required)', $requiredTemplateTypesCount, $templateTypesCount);
}

$objectTypes[] = [
sprintf('%s %s', strtolower($classReflection->getClassTypeDescription()), $classReflection->getDisplayName(false)),
$templateTypesList,
];
$name = sprintf('%s %s', strtolower($classReflection->getClassTypeDescription()), $classReflection->getDisplayName(false));
$objectTypes[$name . "\0" . $templateTypesList] = [$name, $templateTypesList];
Comment thread
staabm marked this conversation as resolved.
return $type;
}

return $traverse($type);
});

return $objectTypes;
return array_values($objectTypes);
Comment thread
staabm marked this conversation as resolved.
}

/**
Expand Down
7 changes: 3 additions & 4 deletions src/Rules/PhpDoc/UnresolvableTypeHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
use PHPStan\Type\NeverType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeTraverser;
use function array_unique;
use function array_values;

#[AutowiredService]
Expand All @@ -20,7 +19,7 @@ public function getUnresolvableType(Type $type): ?UnresolvableTypeResult
{
$containsUnresolvable = false;
$reasons = [];
TypeTraverser::map($type, static function (Type $type, callable $traverse) use (&$containsUnresolvable, &$reasons): Type {
TypeTraverser::mapMemoized($type, static function (Type $type, callable $traverse) use (&$containsUnresolvable, &$reasons): Type {
$reason = null;
if ($type instanceof ErrorType) {
$containsUnresolvable = true;
Expand All @@ -32,7 +31,7 @@ public function getUnresolvableType(Type $type): ?UnresolvableTypeResult
}

if ($reason !== null) {
$reasons[] = $reason;
$reasons[$reason] = $reason;
}

return $containsUnresolvable ? $type : $traverse($type);
Expand All @@ -42,7 +41,7 @@ public function getUnresolvableType(Type $type): ?UnresolvableTypeResult
return null;
}

return new UnresolvableTypeResult(array_values(array_unique($reasons)));
return new UnresolvableTypeResult(array_values($reasons));
}

}
2 changes: 1 addition & 1 deletion src/Rules/RuleLevelHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ private function transformCommonType(Type $type): Type
private function transformAcceptedType(Type $acceptingType, Type $acceptedType): array
{
$checkForUnion = $this->checkUnionTypes;
$acceptedType = TypeTraverser::map($acceptedType, function (Type $acceptedType, callable $traverse) use ($acceptingType, &$checkForUnion): Type {
$acceptedType = TypeTraverser::mapMemoized($acceptedType, function (Type $acceptedType, callable $traverse) use ($acceptingType, &$checkForUnion): Type {
if ($acceptedType instanceof CallableType) {
if ($acceptedType->isCommonCallable()) {
return $acceptedType;
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 = '33df0ad';
public const EXPECTED_EXTENSION_VERSION = 'daff12b';

private static bool $active = false;

Expand Down
2 changes: 1 addition & 1 deletion src/Type/Generic/TemplateTypeHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ public static function resolveToDefaults(Type $type): Type

public static function resolveToBounds(Type $type): Type
{
return TypeTraverser::map($type, static function (Type $type, callable $traverse): Type {
return TypeTraverser::mapMemoized($type, static function (Type $type, callable $traverse): Type {
while ($type instanceof TemplateType) {
$type = $type->getBound();
}
Expand Down
45 changes: 44 additions & 1 deletion src/Type/TypeTraverser.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace PHPStan\Type;

use PHPStan\Turbo\ShadowedByTurboExtension;
use function spl_object_id;

#[ShadowedByTurboExtension(implementation: __DIR__ . '/../../turbo-ext/src/TypeTraverser.cpp')]
final class TypeTraverser
Expand All @@ -11,6 +12,15 @@ final class TypeTraverser
/** @var callable(Type $type, callable(Type): Type $traverse): Type */
private $cb;

/**
* Results of mapMemoized() keyed by spl_object_id() of the mapped type,
* null in map(). The mapped type is kept alive next to its result so that
* its id cannot be reused by another object during the traversal.
*
* @var array<int, array{Type, Type}>|null
*/
private ?array $memo = null;

/**
* Map a Type recursively
*
Expand Down Expand Up @@ -43,6 +53,27 @@ public static function map(Type $type, TypeTraverserCallable|callable $cb): Type
return $self->mapInternal($type);
}

/**
* Like map(), but the callback is called only once for each Type instance:
* a Type instance occurring repeatedly in the traversed type (e.g. a type
* alias used in many offsets of an array shape) is replaced with the result
* of its first occurrence, without traversing it again.
*
* Only for callbacks whose result and side effects do not depend on
* where in the traversed type, or how many times, the type occurs.
*
* @param TypeTraverserCallable|callable(Type $type, callable(Type): Type $traverse): Type $cb
*/
public static function mapMemoized(Type $type, TypeTraverserCallable|callable $cb): Type
{
$self = new self($cb);
$self->memo = [];

$traverser = $self->mapInternal($type);
$self->memo = null;
return $traverser;
}

/** @param TypeTraverserCallable|callable(Type $type, callable(Type): Type $traverse): Type $cb */
private function __construct(TypeTraverserCallable|callable $cb)
{
Expand All @@ -56,7 +87,19 @@ private function __construct(TypeTraverserCallable|callable $cb)
/** @internal */
public function mapInternal(Type $type): Type
{
return ($this->cb)($type, [$this, 'traverseInternal']);
if ($this->memo === null) {
return ($this->cb)($type, [$this, 'traverseInternal']);
}

$id = spl_object_id($type);
if (isset($this->memo[$id])) {
return $this->memo[$id][1];
}

$result = ($this->cb)($type, [$this, 'traverseInternal']);
$this->memo[$id] = [$type, $result];

return $result;
}

/** @internal */
Expand Down
5 changes: 5 additions & 0 deletions tests/PHPStan/Rules/Api/ApiInstanceofTypeRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ public function testRule(): void
40,
$tipText,
],
[
'Doing instanceof PHPStan\Type\Generic\GenericObjectType is error-prone and deprecated.',
52,
$tipText,
],
]);
}

Expand Down
12 changes: 12 additions & 0 deletions tests/PHPStan/Rules/Api/data/instanceof-type.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,18 @@ public function doFoo($a, Type $type)
if ($a instanceof GenericObjectType) {

}

$type = TypeTraverser::mapMemoized($type, function (Type $type, callable $traverse): Type {
if ($type instanceof TypeWithClassName) {
return $type;
}

return $traverse($type);
});

if ($a instanceof GenericObjectType) {

}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -160,11 +160,32 @@ public function testBug14549(): void
46,
MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP,
],
]);
}

public function testRepeatedIterableTypeReportedOnce(): void
{
// the same missing value type or generic class is reported once per parameter,
// regardless of whether the occurrences share a Type instance (type alias) or not
$this->analyse([__DIR__ . '/data/bug-15350.php'], [
[
'Method Bug14549\Foo::doIntersection() has parameter $array with no value type specified in iterable type array.',
46,
'Method Bug15350\\Foo::inline() has parameter $a with no value type specified in iterable type array.',
23,
MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP,
],
[
'Method Bug15350\\Foo::alias() has parameter $a with no value type specified in iterable type array.',
30,
MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP,
],
[
'Method Bug15350\\Foo::inlineGeneric() has parameter $a with generic class Bug15350\\Box but does not specify its types: T',
37,
],
[
'Method Bug15350\\Foo::aliasGeneric() has parameter $a with generic class Bug15350\\Box but does not specify its types: T',
44,
],
]);
}

Expand Down
48 changes: 48 additions & 0 deletions tests/PHPStan/Rules/Methods/data/bug-15350.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

namespace Bug15350;

/**
* @template T
*/
class Box
{

}

/**
* @phpstan-type BareArr array
* @phpstan-type BareBox Box
*/
class Foo
{

/**
* @param callable(array): array $a
*/
public function inline($a): void
{
}

/**
* @param callable(BareArr): BareArr $a
*/
public function alias($a): void
{
}

/**
* @param callable(Box): Box $a
*/
public function inlineGeneric($a): void
{
}

/**
* @param callable(BareBox): BareBox $a
*/
public function aliasGeneric($a): void
{
}

}
Loading
Loading