From e88ea6356c5bdeaceada3e1820f21e740c0e2539 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Mon, 18 May 2026 15:21:53 +0000 Subject: [PATCH 01/13] Introduce `ClosureType::isStaticClosure()` and narrow `Closure::bindTo()`/`Closure::bind()` return type for static closures - Add `isStaticClosure(): TrinaryLogic` to `CallableParametersAcceptor` interface - Add `isStatic` parameter to `ClosureType` constructor, propagated through `equals()`, `describe()`, `traverse()`, `traverseSimultaneously()` - Show `static` prefix in `describe()` at precise level when closure is definitely static; show `non-static`/`static` at cache level when known - Implement `isStaticClosure()` in all `CallableParametersAcceptor` implementations: `CallableType` (maybe), `TrivialParametersAcceptor` (maybe), `FunctionCallableVariant` (no), `InaccessibleMethod` (no), `ExtendedCallableFunctionVariant` and `ResolvedFunctionVariantWithCallable` (propagated from source) - Set `isStatic` from `$expr->static` in `ClosureTypeResolver` for both `Closure` and `ArrowFunction` AST nodes - Add `isStaticClosure` check to `CallableTypeHelper::isParametersAcceptorSuperTypeOf()` - Propagate `isStaticClosure` through `ParametersAcceptorSelector`, `GenericParametersAcceptorResolver`, `ClosureFromCallableDynamicReturnTypeExtension`, `TypeNodeResolver`, and `RuleLevelHelper` - Narrow `Closure::bindTo()` to return `null` for static closures and `Closure|null` for maybe-static closures - Narrow `Closure::bind()` to return `null` for static closures and `Closure|null` for maybe-static closures - Update existing tests to expect `static` prefix in closure type descriptions --- .../Helper/ClosureTypeResolver.php | 3 + src/PhpDoc/TypeNodeResolver.php | 2 +- .../Callables/CallableParametersAcceptor.php | 2 + .../Callables/FunctionCallableVariant.php | 5 + .../ExtendedCallableFunctionVariant.php | 6 + .../GenericParametersAcceptorResolver.php | 1 + src/Reflection/InaccessibleMethod.php | 5 + .../InitializerExprTypeResolver.php | 1 + src/Reflection/ParametersAcceptorSelector.php | 4 + .../ResolvedFunctionVariantWithCallable.php | 6 + src/Reflection/TrivialParametersAcceptor.php | 5 + src/Rules/RuleLevelHelper.php | 1 + src/Type/CallableType.php | 5 + src/Type/CallableTypeHelper.php | 7 + src/Type/ClosureType.php | 80 ++++++++- .../ClosureBindDynamicReturnTypeExtension.php | 10 ++ ...losureBindToDynamicReturnTypeExtension.php | 10 ++ ...FromCallableDynamicReturnTypeExtension.php | 1 + .../Analyser/AnalyserIntegrationTest.php | 12 +- tests/PHPStan/Analyser/data/bug-14324.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-7031.php | 4 +- tests/PHPStan/Analyser/nsrt/bug-9764.php | 2 +- .../Analyser/nsrt/closure-static-type.php | 62 +++++++ .../Analyser/nsrt/degrade-closures.php | 2 +- tests/PHPStan/Type/ClosureTypeTest.php | 152 ++++++++++++++++++ 25 files changed, 377 insertions(+), 13 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/closure-static-type.php diff --git a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php index 665bfcbb982..ff165386f75 100644 --- a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php +++ b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php @@ -227,6 +227,7 @@ static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpu usedVariables: $cachedClosureData['usedVariables'], acceptsNamedArguments: TrinaryLogic::createYes(), mustUseReturnValue: $mustUseReturnValue, + isStatic: TrinaryLogic::createFromBoolean($expr->static), ); } if (self::$resolveClosureTypeDepth >= 2) { @@ -234,6 +235,7 @@ static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpu $parameters, $scope->getFunctionType($expr->returnType, false, false), $isVariadic, + isStatic: TrinaryLogic::createFromBoolean($expr->static), ); } @@ -453,6 +455,7 @@ static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpu usedVariables: $usedVariables, acceptsNamedArguments: TrinaryLogic::createYes(), mustUseReturnValue: $mustUseReturnValue, + isStatic: TrinaryLogic::createFromBoolean($expr->static), ); } diff --git a/src/PhpDoc/TypeNodeResolver.php b/src/PhpDoc/TypeNodeResolver.php index bd243ef7d26..c9194768d4c 100644 --- a/src/PhpDoc/TypeNodeResolver.php +++ b/src/PhpDoc/TypeNodeResolver.php @@ -1041,7 +1041,7 @@ function (CallableTypeParameterNode $parameterNode) use ($nameScope, &$isVariadi ), ]); } elseif ($mainType instanceof ClosureType) { - $closure = new ClosureType($parameters, $returnType, $isVariadic, $templateTypeMap, templateTags: $templateTags, impurePoints: $mainType->getImpurePoints(), invalidateExpressions: $mainType->getInvalidateExpressions(), usedVariables: $mainType->getUsedVariables(), acceptsNamedArguments: $mainType->acceptsNamedArguments(), mustUseReturnValue: $mainType->mustUseReturnValue()); + $closure = new ClosureType($parameters, $returnType, $isVariadic, $templateTypeMap, templateTags: $templateTags, impurePoints: $mainType->getImpurePoints(), invalidateExpressions: $mainType->getInvalidateExpressions(), usedVariables: $mainType->getUsedVariables(), acceptsNamedArguments: $mainType->acceptsNamedArguments(), mustUseReturnValue: $mainType->mustUseReturnValue(), isStatic: $mainType->isStaticClosure()); if ($closure->isPure()->yes() && $returnType->isVoid()->yes()) { return new ErrorType(); } diff --git a/src/Reflection/Callables/CallableParametersAcceptor.php b/src/Reflection/Callables/CallableParametersAcceptor.php index bcef9878eee..df55a609c39 100644 --- a/src/Reflection/Callables/CallableParametersAcceptor.php +++ b/src/Reflection/Callables/CallableParametersAcceptor.php @@ -60,4 +60,6 @@ public function mustUseReturnValue(): TrinaryLogic; public function getAsserts(): Assertions; + public function isStaticClosure(): TrinaryLogic; + } diff --git a/src/Reflection/Callables/FunctionCallableVariant.php b/src/Reflection/Callables/FunctionCallableVariant.php index 6c48e4b0102..2ddf2c1c716 100644 --- a/src/Reflection/Callables/FunctionCallableVariant.php +++ b/src/Reflection/Callables/FunctionCallableVariant.php @@ -179,4 +179,9 @@ public function getAsserts(): Assertions return $this->function->getAsserts(); } + public function isStaticClosure(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + } diff --git a/src/Reflection/ExtendedCallableFunctionVariant.php b/src/Reflection/ExtendedCallableFunctionVariant.php index 389893394e3..2a4fd692a0f 100644 --- a/src/Reflection/ExtendedCallableFunctionVariant.php +++ b/src/Reflection/ExtendedCallableFunctionVariant.php @@ -38,6 +38,7 @@ public function __construct( private TrinaryLogic $acceptsNamedArguments, private TrinaryLogic $mustUseReturnValue, private ?Assertions $assertions = null, + private ?TrinaryLogic $isStatic = null, ) { parent::__construct( @@ -92,4 +93,9 @@ public function getAsserts(): Assertions return $this->assertions ?? Assertions::createEmpty(); } + public function isStaticClosure(): TrinaryLogic + { + return $this->isStatic ?? TrinaryLogic::createMaybe(); + } + } diff --git a/src/Reflection/GenericParametersAcceptorResolver.php b/src/Reflection/GenericParametersAcceptorResolver.php index c72afeedbb5..63d9c2b2513 100644 --- a/src/Reflection/GenericParametersAcceptorResolver.php +++ b/src/Reflection/GenericParametersAcceptorResolver.php @@ -134,6 +134,7 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc $originalParametersAcceptor->acceptsNamedArguments(), $originalParametersAcceptor->mustUseReturnValue(), $originalParametersAcceptor->getAsserts(), + $originalParametersAcceptor->isStaticClosure(), ); } diff --git a/src/Reflection/InaccessibleMethod.php b/src/Reflection/InaccessibleMethod.php index 68fce995f82..f0f1b44ca12 100644 --- a/src/Reflection/InaccessibleMethod.php +++ b/src/Reflection/InaccessibleMethod.php @@ -98,4 +98,9 @@ public function getAsserts(): Assertions return Assertions::createEmpty(); } + public function isStaticClosure(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + } diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index 97ac6041bae..82431b65414 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -263,6 +263,7 @@ public function getType(Expr $expr, InitializerExprContext $context): Type TemplateTypeMap::createEmpty(), TemplateTypeVarianceMap::createEmpty(), acceptsNamedArguments: TrinaryLogic::createYes(), + isStatic: TrinaryLogic::createYes(), ); } if ($expr instanceof Expr\ArrayDimFetch && $expr->dim !== null) { diff --git a/src/Reflection/ParametersAcceptorSelector.php b/src/Reflection/ParametersAcceptorSelector.php index 27fb82d0267..5f5b0f36eb6 100644 --- a/src/Reflection/ParametersAcceptorSelector.php +++ b/src/Reflection/ParametersAcceptorSelector.php @@ -730,6 +730,7 @@ public static function combineAcceptors(array $acceptors): ExtendedParametersAcc $usedVariables = []; $acceptsNamedArguments = TrinaryLogic::createNo(); $mustUseReturnValue = TrinaryLogic::createMaybe(); + $isStaticClosure = TrinaryLogic::createMaybe(); foreach ($acceptors as $acceptor) { $returnTypes[] = $acceptor->getReturnType(); @@ -747,6 +748,7 @@ public static function combineAcceptors(array $acceptors): ExtendedParametersAcc $usedVariables = array_merge($usedVariables, $acceptor->getUsedVariables()); $acceptsNamedArguments = $acceptsNamedArguments->or($acceptor->acceptsNamedArguments()); $mustUseReturnValue = $mustUseReturnValue->or($acceptor->mustUseReturnValue()); + $isStaticClosure = $isStaticClosure->or($acceptor->isStaticClosure()); } $isVariadic = $isVariadic || $acceptor->isVariadic(); @@ -854,6 +856,7 @@ public static function combineAcceptors(array $acceptors): ExtendedParametersAcc $usedVariables, $acceptsNamedArguments, $mustUseReturnValue, + isStatic: $isStaticClosure, ); } @@ -892,6 +895,7 @@ private static function wrapAcceptor(ParametersAcceptor $acceptor): ExtendedPara $acceptor->acceptsNamedArguments(), $acceptor->mustUseReturnValue(), $acceptor->getAsserts(), + $acceptor->isStaticClosure(), ); } diff --git a/src/Reflection/ResolvedFunctionVariantWithCallable.php b/src/Reflection/ResolvedFunctionVariantWithCallable.php index 6f816fa0ac9..7e57be7a952 100644 --- a/src/Reflection/ResolvedFunctionVariantWithCallable.php +++ b/src/Reflection/ResolvedFunctionVariantWithCallable.php @@ -30,6 +30,7 @@ public function __construct( private TrinaryLogic $acceptsNamedArguments, private TrinaryLogic $mustUseReturnValue, private ?Assertions $assertions = null, + private ?TrinaryLogic $isStatic = null, ) { } @@ -124,4 +125,9 @@ public function getAsserts(): Assertions return $this->assertions ?? Assertions::createEmpty(); } + public function isStaticClosure(): TrinaryLogic + { + return $this->isStatic ?? TrinaryLogic::createMaybe(); + } + } diff --git a/src/Reflection/TrivialParametersAcceptor.php b/src/Reflection/TrivialParametersAcceptor.php index 157368d4c02..1aaefa6a33d 100644 --- a/src/Reflection/TrivialParametersAcceptor.php +++ b/src/Reflection/TrivialParametersAcceptor.php @@ -108,4 +108,9 @@ public function getAsserts(): Assertions return Assertions::createEmpty(); } + public function isStaticClosure(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + } diff --git a/src/Rules/RuleLevelHelper.php b/src/Rules/RuleLevelHelper.php index 9c055399356..1c1bd926f90 100644 --- a/src/Rules/RuleLevelHelper.php +++ b/src/Rules/RuleLevelHelper.php @@ -123,6 +123,7 @@ private function transformAcceptedType(Type $acceptingType, Type $acceptedType): $acceptedType->getUsedVariables(), $acceptedType->acceptsNamedArguments(), $acceptedType->mustUseReturnValue(), + isStatic: $acceptedType->isStaticClosure(), ); } diff --git a/src/Type/CallableType.php b/src/Type/CallableType.php index c2760ef583e..bea8c783ba0 100644 --- a/src/Type/CallableType.php +++ b/src/Type/CallableType.php @@ -404,6 +404,11 @@ public function getAsserts(): Assertions return Assertions::createEmpty(); } + public function isStaticClosure(): TrinaryLogic + { + return TrinaryLogic::createMaybe(); + } + public function toNumber(): Type { return new ErrorType(); diff --git a/src/Type/CallableTypeHelper.php b/src/Type/CallableTypeHelper.php index 2f5b9f89b29..709ed1fb610 100644 --- a/src/Type/CallableTypeHelper.php +++ b/src/Type/CallableTypeHelper.php @@ -113,6 +113,13 @@ public static function isParametersAcceptorSuperTypeOf( $result = $result->and(new IsSuperTypeOfResult($theirs->isPure()->negate(), [])); } + $ourStatic = $ours->isStaticClosure(); + if ($ourStatic->yes()) { + $result = $result->and(new IsSuperTypeOfResult($theirs->isStaticClosure(), [])); + } elseif ($ourStatic->no()) { + $result = $result->and(new IsSuperTypeOfResult($theirs->isStaticClosure()->negate(), [])); + } + return $result->and($isReturnTypeSuperType); } diff --git a/src/Type/ClosureType.php b/src/Type/ClosureType.php index a819526021f..6aeedce0f34 100644 --- a/src/Type/ClosureType.php +++ b/src/Type/ClosureType.php @@ -88,6 +88,8 @@ class ClosureType implements TypeWithClassName, CallableParametersAcceptor private Assertions $assertions; + private TrinaryLogic $isStatic; + /** * @api * @param list|null $parameters @@ -112,6 +114,7 @@ public function __construct( ?TrinaryLogic $acceptsNamedArguments = null, ?TrinaryLogic $mustUseReturnValue = null, ?Assertions $assertions = null, + ?TrinaryLogic $isStatic = null, ) { if ($acceptsNamedArguments === null) { @@ -132,6 +135,7 @@ public function __construct( $this->callSiteVarianceMap = $callSiteVarianceMap ?? TemplateTypeVarianceMap::createEmpty(); $this->impurePoints = $impurePoints ?? [new SimpleImpurePoint('functionCall', 'call to an unknown Closure', false)]; $this->assertions = $assertions ?? Assertions::createEmpty(); + $this->isStatic = $isStatic ?? TrinaryLogic::createMaybe(); } public function getAsserts(): Assertions @@ -268,7 +272,8 @@ public function equals(Type $type): bool } return $this->describe(VerbosityLevel::precise()) === $type->describe(VerbosityLevel::precise()) - && $this->isPure()->equals($type->isPure()); + && $this->isPure()->equals($type->isPure()) + && $this->isStatic->equals($type->isStatic); } public function describe(VerbosityLevel $level): string @@ -306,6 +311,72 @@ function (): string { return $printer->print($selfWithoutParameterNames->toPhpDocNode()); }, + function (): string { + $prefix = $this->isStatic->yes() ? 'static ' : ''; + if ($this->isCommonCallable) { + $name = $this->isPure()->yes() ? 'pure-Closure' : 'Closure'; + return $prefix . $name; + } + + $printer = new Printer(); + $selfWithoutParameterNames = new self( + array_map(static fn (ParameterReflection $p): ParameterReflection => new DummyParameter( + '', + $p->getType(), + optional: $p->isOptional() && !$p->isVariadic(), + passedByReference: PassedByReference::createNo(), + variadic: $p->isVariadic(), + defaultValue: $p->getDefaultValue(), + ), $this->parameters), + $this->returnType, + $this->variadic, + $this->templateTypeMap, + $this->resolvedTemplateTypeMap, + $this->callSiteVarianceMap, + $this->templateTags, + $this->throwPoints, + $this->impurePoints, + $this->invalidateExpressions, + $this->usedVariables, + $this->acceptsNamedArguments, + $this->mustUseReturnValue, + ); + + return $prefix . $printer->print($selfWithoutParameterNames->toPhpDocNode()); + }, + function (): string { + $prefix = !$this->isStatic->maybe() ? ($this->isStatic->yes() ? 'static ' : 'non-static ') : ''; + if ($this->isCommonCallable) { + $name = $this->isPure()->yes() ? 'pure-Closure' : 'Closure'; + return $prefix . $name; + } + + $printer = new Printer(); + $selfWithoutParameterNames = new self( + array_map(static fn (ParameterReflection $p): ParameterReflection => new DummyParameter( + '', + $p->getType(), + optional: $p->isOptional() && !$p->isVariadic(), + passedByReference: PassedByReference::createNo(), + variadic: $p->isVariadic(), + defaultValue: $p->getDefaultValue(), + ), $this->parameters), + $this->returnType, + $this->variadic, + $this->templateTypeMap, + $this->resolvedTemplateTypeMap, + $this->callSiteVarianceMap, + $this->templateTags, + $this->throwPoints, + $this->impurePoints, + $this->invalidateExpressions, + $this->usedVariables, + $this->acceptsNamedArguments, + $this->mustUseReturnValue, + ); + + return $prefix . $printer->print($selfWithoutParameterNames->toPhpDocNode()); + }, ); } @@ -496,6 +567,11 @@ public function mustUseReturnValue(): TrinaryLogic return $this->mustUseReturnValue; } + public function isStaticClosure(): TrinaryLogic + { + return $this->isStatic; + } + public function isCloneable(): TrinaryLogic { return TrinaryLogic::createYes(); @@ -709,6 +785,7 @@ public function traverse(callable $cb): Type $this->acceptsNamedArguments, $this->mustUseReturnValue, $this->assertions, + $this->isStatic, ); } @@ -761,6 +838,7 @@ public function traverseSimultaneously(Type $right, callable $cb): Type $this->acceptsNamedArguments, $this->mustUseReturnValue, $this->assertions, + $this->isStatic, ); } diff --git a/src/Type/Php/ClosureBindDynamicReturnTypeExtension.php b/src/Type/Php/ClosureBindDynamicReturnTypeExtension.php index 580595acd74..10bc9b64255 100644 --- a/src/Type/Php/ClosureBindDynamicReturnTypeExtension.php +++ b/src/Type/Php/ClosureBindDynamicReturnTypeExtension.php @@ -9,7 +9,9 @@ use PHPStan\Reflection\MethodReflection; use PHPStan\Type\ClosureType; use PHPStan\Type\DynamicStaticMethodReturnTypeExtension; +use PHPStan\Type\NullType; use PHPStan\Type\Type; +use PHPStan\Type\TypeCombinator; #[AutowiredService] final class ClosureBindDynamicReturnTypeExtension implements DynamicStaticMethodReturnTypeExtension @@ -32,6 +34,14 @@ public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, return null; } + if ($closureType->isStaticClosure()->yes()) { + return new NullType(); + } + + if ($closureType->isStaticClosure()->maybe()) { + return TypeCombinator::union($closureType, new NullType()); + } + return $closureType; } diff --git a/src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php b/src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php index 3e275677e27..183eaa3d702 100644 --- a/src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php +++ b/src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php @@ -9,7 +9,9 @@ use PHPStan\Reflection\MethodReflection; use PHPStan\Type\ClosureType; use PHPStan\Type\DynamicMethodReturnTypeExtension; +use PHPStan\Type\NullType; use PHPStan\Type\Type; +use PHPStan\Type\TypeCombinator; #[AutowiredService] final class ClosureBindToDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension @@ -32,6 +34,14 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method return null; } + if ($closureType->isStaticClosure()->yes()) { + return new NullType(); + } + + if ($closureType->isStaticClosure()->maybe()) { + return TypeCombinator::union($closureType, new NullType()); + } + return $closureType; } diff --git a/src/Type/Php/ClosureFromCallableDynamicReturnTypeExtension.php b/src/Type/Php/ClosureFromCallableDynamicReturnTypeExtension.php index df0e2d54aa2..1bccae3ac1a 100644 --- a/src/Type/Php/ClosureFromCallableDynamicReturnTypeExtension.php +++ b/src/Type/Php/ClosureFromCallableDynamicReturnTypeExtension.php @@ -56,6 +56,7 @@ public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, usedVariables: $variant->getUsedVariables(), acceptsNamedArguments: $variant->acceptsNamedArguments(), mustUseReturnValue: $variant->mustUseReturnValue(), + isStatic: $variant->isStaticClosure(), ); } diff --git a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php index eef38313008..05cdf39be3f 100644 --- a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php +++ b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php @@ -471,15 +471,15 @@ public function testBug4715(): void #[RequiresPhp('>= 8.2.0')] public function testBug4734(): void { - // false positive $errors = $this->runAnalyse(__DIR__ . '/data/bug-4734.php'); - $this->assertCount(5, $errors); // could be 3 + $this->assertCount(6, $errors); - $this->assertSame('Static property Bug4734\Foo::$httpMethodParameterOverride (bool) is never assigned false so the property type can be changed to true.', $errors[0]->getMessage()); // should not error - $this->assertSame('Property Bug4734\Foo::$httpMethodParameterOverride2 (bool) is never assigned false so the property type can be changed to true.', $errors[1]->getMessage()); // should not error + $this->assertSame('Static property Bug4734\Foo::$httpMethodParameterOverride (bool) is never assigned false so the property type can be changed to true.', $errors[0]->getMessage()); + $this->assertSame('Property Bug4734\Foo::$httpMethodParameterOverride2 (bool) is never assigned false so the property type can be changed to true.', $errors[1]->getMessage()); $this->assertSame('Unsafe access to private property Bug4734\Foo::$httpMethodParameterOverride through static::.', $errors[2]->getMessage()); - $this->assertSame('Access to an undefined static property static(Bug4734\Foo)::$httpMethodParameterOverride3.', $errors[3]->getMessage()); - $this->assertSame('Access to an undefined property Bug4734\Foo::$httpMethodParameterOverride4.', $errors[4]->getMessage()); + $this->assertSame('Trying to invoke null but it\'s not a callable.', $errors[3]->getMessage()); + $this->assertSame('Access to an undefined static property static(Bug4734\Foo)::$httpMethodParameterOverride3.', $errors[4]->getMessage()); + $this->assertSame('Access to an undefined property Bug4734\Foo::$httpMethodParameterOverride4.', $errors[5]->getMessage()); } #[RequiresPhp('>= 8.1.0')] diff --git a/tests/PHPStan/Analyser/data/bug-14324.php b/tests/PHPStan/Analyser/data/bug-14324.php index 20f97172fb9..4bd26ed562c 100644 --- a/tests/PHPStan/Analyser/data/bug-14324.php +++ b/tests/PHPStan/Analyser/data/bug-14324.php @@ -50,7 +50,7 @@ public function createMap(): void 'bar2' => static fn() => 'bar2', 'baz2' => static fn() => 'baz2', ]; - assertType("array{foo: Closure(): 'foo', bar: Closure(): 'bar', baz: Closure(): 'baz', qux: Closure(): 'qux', quux: Closure(): 'quux', corge: Closure(): 'corge', grault: Closure(): 'grault', garply: Closure(): 'garply', waldo: Closure(): 'waldo', fred: Closure(): 'fred', plugh: Closure(): 'plugh', xyzzy: Closure(): 'xyzzy', thud: Closure(): 'thud', foo1: Closure(): 'foo1', bar1: Closure(): 'bar1', baz1: Closure(): 'baz1', qux1: Closure(): 'qux1', quux1: Closure(): 'quux1', corge1: Closure(): 'corge1', grault1: Closure(): 'grault1', garply1: Closure(): 'garply1', waldo1: Closure(): 'waldo1', fred1: Closure(): 'fred1', plugh1: Closure(): 'plugh1', xyzzy1: Closure(): 'xyzzy1', thud1: Closure(): 'thud1', foo2: Closure(): 'foo2', bar2: Closure(): 'bar2', baz2: Closure(): 'baz2'}", self::$map); + assertType("array{foo: static Closure(): 'foo', bar: static Closure(): 'bar', baz: static Closure(): 'baz', qux: static Closure(): 'qux', quux: static Closure(): 'quux', corge: static Closure(): 'corge', grault: static Closure(): 'grault', garply: static Closure(): 'garply', waldo: static Closure(): 'waldo', fred: static Closure(): 'fred', plugh: static Closure(): 'plugh', xyzzy: static Closure(): 'xyzzy', thud: static Closure(): 'thud', foo1: static Closure(): 'foo1', bar1: static Closure(): 'bar1', baz1: static Closure(): 'baz1', qux1: static Closure(): 'qux1', quux1: static Closure(): 'quux1', corge1: static Closure(): 'corge1', grault1: static Closure(): 'grault1', garply1: static Closure(): 'garply1', waldo1: static Closure(): 'waldo1', fred1: static Closure(): 'fred1', plugh1: static Closure(): 'plugh1', xyzzy1: static Closure(): 'xyzzy1', thud1: static Closure(): 'thud1', foo2: static Closure(): 'foo2', bar2: static Closure(): 'bar2', baz2: static Closure(): 'baz2'}", self::$map); foreach (self::ADDITIONAL_MAPS as $map) { // added with 3 entries, breaching the closure limit of 32 entries diff --git a/tests/PHPStan/Analyser/nsrt/bug-7031.php b/tests/PHPStan/Analyser/nsrt/bug-7031.php index a325a67d1f2..a561e9c6232 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-7031.php +++ b/tests/PHPStan/Analyser/nsrt/bug-7031.php @@ -7,6 +7,6 @@ class SomeKey {} function () { - assertType('Closure(int): Generator', static fn(int $value): iterable => yield new SomeKey); - assertType('Closure(int): Generator', static function (int $value): iterable { yield new SomeKey; }); + assertType('static Closure(int): Generator', static fn(int $value): iterable => yield new SomeKey); + assertType('static Closure(int): Generator', static function (int $value): iterable { yield new SomeKey; }); }; diff --git a/tests/PHPStan/Analyser/nsrt/bug-9764.php b/tests/PHPStan/Analyser/nsrt/bug-9764.php index f24b810fe8d..73110fb4f7f 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-9764.php +++ b/tests/PHPStan/Analyser/nsrt/bug-9764.php @@ -18,7 +18,7 @@ function (): void { /** @var array $a */ $a = []; $c = static fn (): array => $a; - assertType('Closure(): array', $c); + assertType('static Closure(): array', $c); $r = result($c); assertType('array', $r); diff --git a/tests/PHPStan/Analyser/nsrt/closure-static-type.php b/tests/PHPStan/Analyser/nsrt/closure-static-type.php new file mode 100644 index 00000000000..bccfc58afcf --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/closure-static-type.php @@ -0,0 +1,62 @@ + 1; + assertType('static Closure(): 1', $staticArrow); + + $nonStaticArrow = fn (): int => 1; + assertType('Closure(): 1', $nonStaticArrow); + } + + public function doBindTo(): void + { + $static = static function (): void {}; + assertType('null', $static->bindTo($this)); + + $nonStatic = function (): void {}; + assertType('Closure(): void', $nonStatic->bindTo($this)); + } + + public function doBind(): void + { + $static = static function (): void {}; + assertType('null', Closure::bind($static, $this)); + + $nonStatic = function (): void {}; + assertType('Closure(): void', Closure::bind($nonStatic, $this)); + } + + /** + * @param Closure(): void $unknownClosure + */ + public function doUnknown(Closure $unknownClosure): void + { + assertType('(Closure(): void)|null', $unknownClosure->bindTo($this)); + assertType('(Closure(): void)|null', Closure::bind($unknownClosure, $this)); + } + + public function doFromCallable(): void + { + $fn = Closure::fromCallable(static function (): void {}); + assertType('null', $fn->bindTo($this)); + + $fn2 = Closure::fromCallable(function (): void {}); + assertType('Closure(): void', $fn2->bindTo($this)); + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/degrade-closures.php b/tests/PHPStan/Analyser/nsrt/degrade-closures.php index a7be72480e7..66df7d08afd 100644 --- a/tests/PHPStan/Analyser/nsrt/degrade-closures.php +++ b/tests/PHPStan/Analyser/nsrt/degrade-closures.php @@ -36,7 +36,7 @@ $arr[] = static function () {}; $arr[] = static function () {}; $arr[] = static function () {}; -assertType('array{Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void}', $arr); +assertType('array{static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void}', $arr); $arr[] = static function () {}; assertType('non-empty-list&oversized-array', $arr); diff --git a/tests/PHPStan/Type/ClosureTypeTest.php b/tests/PHPStan/Type/ClosureTypeTest.php index f5ef07239e4..c1a01d37925 100644 --- a/tests/PHPStan/Type/ClosureTypeTest.php +++ b/tests/PHPStan/Type/ClosureTypeTest.php @@ -89,6 +89,41 @@ public static function dataIsSuperTypeOf(): array new ObjectWithoutClassType(new ObjectType(Closure::class)), TrinaryLogic::createNo(), ], + 'static closure is supertype of static closure' => [ + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createYes()), + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createYes()), + TrinaryLogic::createYes(), + ], + 'static closure is not supertype of non-static closure' => [ + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createYes()), + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createNo()), + TrinaryLogic::createNo(), + ], + 'non-static closure is not supertype of static closure' => [ + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createNo()), + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createYes()), + TrinaryLogic::createNo(), + ], + 'non-static closure is supertype of non-static closure' => [ + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createNo()), + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createNo()), + TrinaryLogic::createYes(), + ], + 'maybe-static closure is supertype of static closure' => [ + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createMaybe()), + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createYes()), + TrinaryLogic::createYes(), + ], + 'maybe-static closure is supertype of non-static closure' => [ + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createMaybe()), + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createNo()), + TrinaryLogic::createYes(), + ], + 'static closure is maybe supertype of maybe-static closure' => [ + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createYes()), + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createMaybe()), + TrinaryLogic::createMaybe(), + ], ]; } @@ -107,4 +142,121 @@ public function testIsSuperTypeOf( ); } + public static function dataEquals(): array + { + return [ + 'static equals static' => [ + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createYes()), + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createYes()), + true, + ], + 'static does not equal non-static' => [ + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createYes()), + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createNo()), + false, + ], + 'static does not equal maybe-static' => [ + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createYes()), + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createMaybe()), + false, + ], + 'maybe-static equals maybe-static' => [ + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createMaybe()), + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createMaybe()), + true, + ], + ]; + } + + #[DataProvider('dataEquals')] + public function testEquals( + ClosureType $type, + ClosureType $otherType, + bool $expectedResult, + ): void + { + $this->assertSame($expectedResult, $type->equals($otherType)); + } + + public static function dataDescribe(): array + { + return [ + 'static closure at typeOnly' => [ + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createYes()), + VerbosityLevel::typeOnly(), + 'Closure', + ], + 'static closure at value' => [ + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createYes()), + VerbosityLevel::value(), + 'Closure(): mixed', + ], + 'static closure at precise' => [ + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createYes()), + VerbosityLevel::precise(), + 'static Closure(): mixed', + ], + 'static closure at cache' => [ + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createYes()), + VerbosityLevel::cache(), + 'static Closure(): mixed', + ], + 'non-static closure at precise' => [ + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createNo()), + VerbosityLevel::precise(), + 'Closure(): mixed', + ], + 'non-static closure at cache' => [ + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createNo()), + VerbosityLevel::cache(), + 'non-static Closure(): mixed', + ], + 'maybe-static closure at precise' => [ + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createMaybe()), + VerbosityLevel::precise(), + 'Closure(): mixed', + ], + 'maybe-static closure at cache' => [ + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createMaybe()), + VerbosityLevel::cache(), + 'Closure(): mixed', + ], + 'static common closure at precise' => [ + new ClosureType(isStatic: TrinaryLogic::createYes()), + VerbosityLevel::precise(), + 'static Closure', + ], + 'static common closure at cache' => [ + new ClosureType(isStatic: TrinaryLogic::createYes()), + VerbosityLevel::cache(), + 'static Closure', + ], + ]; + } + + #[DataProvider('dataDescribe')] + public function testDescribe( + ClosureType $type, + VerbosityLevel $level, + string $expectedDescription, + ): void + { + $this->assertSame($expectedDescription, $type->describe($level)); + } + + public function testIsStaticClosure(): void + { + $staticClosure = new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createYes()); + $this->assertTrue($staticClosure->isStaticClosure()->yes()); + + $nonStaticClosure = new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createNo()); + $this->assertTrue($nonStaticClosure->isStaticClosure()->no()); + + $maybeClosure = new ClosureType([], new MixedType(), false); + $this->assertTrue($maybeClosure->isStaticClosure()->maybe()); + + $defaultClosure = new ClosureType(); + $this->assertTrue($defaultClosure->isStaticClosure()->maybe()); + } + } From 856387732cd6d1ecc26eaf3760202ca27dacbfda Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 19 May 2026 21:43:07 +0000 Subject: [PATCH 02/13] Revert `Closure::bindTo()`/`Closure::bind()` return type narrowing for static closures This will be done in a separate PR. Co-Authored-By: Claude Opus 4.6 --- .../Php/ClosureBindDynamicReturnTypeExtension.php | 10 ---------- .../ClosureBindToDynamicReturnTypeExtension.php | 10 ---------- tests/PHPStan/Analyser/AnalyserIntegrationTest.php | 12 ++++++------ .../PHPStan/Analyser/nsrt/closure-static-type.php | 14 +++++++------- 4 files changed, 13 insertions(+), 33 deletions(-) diff --git a/src/Type/Php/ClosureBindDynamicReturnTypeExtension.php b/src/Type/Php/ClosureBindDynamicReturnTypeExtension.php index 10bc9b64255..580595acd74 100644 --- a/src/Type/Php/ClosureBindDynamicReturnTypeExtension.php +++ b/src/Type/Php/ClosureBindDynamicReturnTypeExtension.php @@ -9,9 +9,7 @@ use PHPStan\Reflection\MethodReflection; use PHPStan\Type\ClosureType; use PHPStan\Type\DynamicStaticMethodReturnTypeExtension; -use PHPStan\Type\NullType; use PHPStan\Type\Type; -use PHPStan\Type\TypeCombinator; #[AutowiredService] final class ClosureBindDynamicReturnTypeExtension implements DynamicStaticMethodReturnTypeExtension @@ -34,14 +32,6 @@ public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, return null; } - if ($closureType->isStaticClosure()->yes()) { - return new NullType(); - } - - if ($closureType->isStaticClosure()->maybe()) { - return TypeCombinator::union($closureType, new NullType()); - } - return $closureType; } diff --git a/src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php b/src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php index 183eaa3d702..3e275677e27 100644 --- a/src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php +++ b/src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php @@ -9,9 +9,7 @@ use PHPStan\Reflection\MethodReflection; use PHPStan\Type\ClosureType; use PHPStan\Type\DynamicMethodReturnTypeExtension; -use PHPStan\Type\NullType; use PHPStan\Type\Type; -use PHPStan\Type\TypeCombinator; #[AutowiredService] final class ClosureBindToDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension @@ -34,14 +32,6 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method return null; } - if ($closureType->isStaticClosure()->yes()) { - return new NullType(); - } - - if ($closureType->isStaticClosure()->maybe()) { - return TypeCombinator::union($closureType, new NullType()); - } - return $closureType; } diff --git a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php index 05cdf39be3f..eef38313008 100644 --- a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php +++ b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php @@ -471,15 +471,15 @@ public function testBug4715(): void #[RequiresPhp('>= 8.2.0')] public function testBug4734(): void { + // false positive $errors = $this->runAnalyse(__DIR__ . '/data/bug-4734.php'); - $this->assertCount(6, $errors); + $this->assertCount(5, $errors); // could be 3 - $this->assertSame('Static property Bug4734\Foo::$httpMethodParameterOverride (bool) is never assigned false so the property type can be changed to true.', $errors[0]->getMessage()); - $this->assertSame('Property Bug4734\Foo::$httpMethodParameterOverride2 (bool) is never assigned false so the property type can be changed to true.', $errors[1]->getMessage()); + $this->assertSame('Static property Bug4734\Foo::$httpMethodParameterOverride (bool) is never assigned false so the property type can be changed to true.', $errors[0]->getMessage()); // should not error + $this->assertSame('Property Bug4734\Foo::$httpMethodParameterOverride2 (bool) is never assigned false so the property type can be changed to true.', $errors[1]->getMessage()); // should not error $this->assertSame('Unsafe access to private property Bug4734\Foo::$httpMethodParameterOverride through static::.', $errors[2]->getMessage()); - $this->assertSame('Trying to invoke null but it\'s not a callable.', $errors[3]->getMessage()); - $this->assertSame('Access to an undefined static property static(Bug4734\Foo)::$httpMethodParameterOverride3.', $errors[4]->getMessage()); - $this->assertSame('Access to an undefined property Bug4734\Foo::$httpMethodParameterOverride4.', $errors[5]->getMessage()); + $this->assertSame('Access to an undefined static property static(Bug4734\Foo)::$httpMethodParameterOverride3.', $errors[3]->getMessage()); + $this->assertSame('Access to an undefined property Bug4734\Foo::$httpMethodParameterOverride4.', $errors[4]->getMessage()); } #[RequiresPhp('>= 8.1.0')] diff --git a/tests/PHPStan/Analyser/nsrt/closure-static-type.php b/tests/PHPStan/Analyser/nsrt/closure-static-type.php index bccfc58afcf..6b84bd967df 100644 --- a/tests/PHPStan/Analyser/nsrt/closure-static-type.php +++ b/tests/PHPStan/Analyser/nsrt/closure-static-type.php @@ -11,13 +11,13 @@ final class Foo public function doFoo(): void { $static = static function (): void {}; - assertType('static Closure(): void', $static); + assertType('static-Closure(): void', $static); $nonStatic = function (): void {}; assertType('Closure(): void', $nonStatic); $staticArrow = static fn (): int => 1; - assertType('static Closure(): 1', $staticArrow); + assertType('static-Closure(): 1', $staticArrow); $nonStaticArrow = fn (): int => 1; assertType('Closure(): 1', $nonStaticArrow); @@ -26,7 +26,7 @@ public function doFoo(): void public function doBindTo(): void { $static = static function (): void {}; - assertType('null', $static->bindTo($this)); + assertType('static-Closure(): void', $static->bindTo($this)); $nonStatic = function (): void {}; assertType('Closure(): void', $nonStatic->bindTo($this)); @@ -35,7 +35,7 @@ public function doBindTo(): void public function doBind(): void { $static = static function (): void {}; - assertType('null', Closure::bind($static, $this)); + assertType('static-Closure(): void', Closure::bind($static, $this)); $nonStatic = function (): void {}; assertType('Closure(): void', Closure::bind($nonStatic, $this)); @@ -46,14 +46,14 @@ public function doBind(): void */ public function doUnknown(Closure $unknownClosure): void { - assertType('(Closure(): void)|null', $unknownClosure->bindTo($this)); - assertType('(Closure(): void)|null', Closure::bind($unknownClosure, $this)); + assertType('Closure(): void', $unknownClosure->bindTo($this)); + assertType('Closure(): void', Closure::bind($unknownClosure, $this)); } public function doFromCallable(): void { $fn = Closure::fromCallable(static function (): void {}); - assertType('null', $fn->bindTo($this)); + assertType('static-Closure(): void', $fn->bindTo($this)); $fn2 = Closure::fromCallable(function (): void {}); assertType('Closure(): void', $fn2->bindTo($this)); From ecf42bb9d81470d64dd2dcb23a2f0410fb505d25 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 19 May 2026 21:43:17 +0000 Subject: [PATCH 03/13] Use hyphen in static closure prefix and extract `describeWithPrefix()` helper - Change prefix from `static ` to `static-` to match the `pure-Closure` convention - Remove `non-static` prefix at cache level (consistent with not using `non-pure`) - Extract `describeWithPrefix()` private method to deduplicate value/precise callbacks Co-Authored-By: Claude Opus 4.6 --- src/Type/ClosureType.php | 129 +++++------------- tests/PHPStan/Analyser/data/bug-14324.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-7031.php | 4 +- tests/PHPStan/Analyser/nsrt/bug-9764.php | 2 +- .../Analyser/nsrt/degrade-closures.php | 2 +- tests/PHPStan/Type/ClosureTypeTest.php | 10 +- 6 files changed, 44 insertions(+), 105 deletions(-) diff --git a/src/Type/ClosureType.php b/src/Type/ClosureType.php index 6aeedce0f34..9085797a96b 100644 --- a/src/Type/ClosureType.php +++ b/src/Type/ClosureType.php @@ -280,104 +280,43 @@ public function describe(VerbosityLevel $level): string { return $level->handle( static fn (): string => 'Closure', - function (): string { - if ($this->isCommonCallable) { - return $this->isPure()->yes() ? 'pure-Closure' : 'Closure'; - } - - $printer = new Printer(); - $selfWithoutParameterNames = new self( - array_map(static fn (ParameterReflection $p): ParameterReflection => new DummyParameter( - '', - $p->getType(), - optional: $p->isOptional() && !$p->isVariadic(), - passedByReference: PassedByReference::createNo(), - variadic: $p->isVariadic(), - defaultValue: $p->getDefaultValue(), - ), $this->parameters), - $this->returnType, - $this->variadic, - $this->templateTypeMap, - $this->resolvedTemplateTypeMap, - $this->callSiteVarianceMap, - $this->templateTags, - $this->throwPoints, - $this->impurePoints, - $this->invalidateExpressions, - $this->usedVariables, - $this->acceptsNamedArguments, - $this->mustUseReturnValue, - ); - - return $printer->print($selfWithoutParameterNames->toPhpDocNode()); - }, - function (): string { - $prefix = $this->isStatic->yes() ? 'static ' : ''; - if ($this->isCommonCallable) { - $name = $this->isPure()->yes() ? 'pure-Closure' : 'Closure'; - return $prefix . $name; - } - - $printer = new Printer(); - $selfWithoutParameterNames = new self( - array_map(static fn (ParameterReflection $p): ParameterReflection => new DummyParameter( - '', - $p->getType(), - optional: $p->isOptional() && !$p->isVariadic(), - passedByReference: PassedByReference::createNo(), - variadic: $p->isVariadic(), - defaultValue: $p->getDefaultValue(), - ), $this->parameters), - $this->returnType, - $this->variadic, - $this->templateTypeMap, - $this->resolvedTemplateTypeMap, - $this->callSiteVarianceMap, - $this->templateTags, - $this->throwPoints, - $this->impurePoints, - $this->invalidateExpressions, - $this->usedVariables, - $this->acceptsNamedArguments, - $this->mustUseReturnValue, - ); + fn (): string => $this->describeWithPrefix(''), + fn (): string => $this->describeWithPrefix($this->isStatic->yes() ? 'static-' : ''), + ); + } - return $prefix . $printer->print($selfWithoutParameterNames->toPhpDocNode()); - }, - function (): string { - $prefix = !$this->isStatic->maybe() ? ($this->isStatic->yes() ? 'static ' : 'non-static ') : ''; - if ($this->isCommonCallable) { - $name = $this->isPure()->yes() ? 'pure-Closure' : 'Closure'; - return $prefix . $name; - } - - $printer = new Printer(); - $selfWithoutParameterNames = new self( - array_map(static fn (ParameterReflection $p): ParameterReflection => new DummyParameter( - '', - $p->getType(), - optional: $p->isOptional() && !$p->isVariadic(), - passedByReference: PassedByReference::createNo(), - variadic: $p->isVariadic(), - defaultValue: $p->getDefaultValue(), - ), $this->parameters), - $this->returnType, - $this->variadic, - $this->templateTypeMap, - $this->resolvedTemplateTypeMap, - $this->callSiteVarianceMap, - $this->templateTags, - $this->throwPoints, - $this->impurePoints, - $this->invalidateExpressions, - $this->usedVariables, - $this->acceptsNamedArguments, - $this->mustUseReturnValue, - ); + private function describeWithPrefix(string $prefix): string + { + if ($this->isCommonCallable) { + $name = $this->isPure()->yes() ? 'pure-Closure' : 'Closure'; + return $prefix . $name; + } - return $prefix . $printer->print($selfWithoutParameterNames->toPhpDocNode()); - }, + $printer = new Printer(); + $selfWithoutParameterNames = new self( + array_map(static fn (ParameterReflection $p): ParameterReflection => new DummyParameter( + '', + $p->getType(), + optional: $p->isOptional() && !$p->isVariadic(), + passedByReference: PassedByReference::createNo(), + variadic: $p->isVariadic(), + defaultValue: $p->getDefaultValue(), + ), $this->parameters), + $this->returnType, + $this->variadic, + $this->templateTypeMap, + $this->resolvedTemplateTypeMap, + $this->callSiteVarianceMap, + $this->templateTags, + $this->throwPoints, + $this->impurePoints, + $this->invalidateExpressions, + $this->usedVariables, + $this->acceptsNamedArguments, + $this->mustUseReturnValue, ); + + return $prefix . $printer->print($selfWithoutParameterNames->toPhpDocNode()); } public function isOffsetAccessLegal(): TrinaryLogic diff --git a/tests/PHPStan/Analyser/data/bug-14324.php b/tests/PHPStan/Analyser/data/bug-14324.php index 4bd26ed562c..37f7a1db2f7 100644 --- a/tests/PHPStan/Analyser/data/bug-14324.php +++ b/tests/PHPStan/Analyser/data/bug-14324.php @@ -50,7 +50,7 @@ public function createMap(): void 'bar2' => static fn() => 'bar2', 'baz2' => static fn() => 'baz2', ]; - assertType("array{foo: static Closure(): 'foo', bar: static Closure(): 'bar', baz: static Closure(): 'baz', qux: static Closure(): 'qux', quux: static Closure(): 'quux', corge: static Closure(): 'corge', grault: static Closure(): 'grault', garply: static Closure(): 'garply', waldo: static Closure(): 'waldo', fred: static Closure(): 'fred', plugh: static Closure(): 'plugh', xyzzy: static Closure(): 'xyzzy', thud: static Closure(): 'thud', foo1: static Closure(): 'foo1', bar1: static Closure(): 'bar1', baz1: static Closure(): 'baz1', qux1: static Closure(): 'qux1', quux1: static Closure(): 'quux1', corge1: static Closure(): 'corge1', grault1: static Closure(): 'grault1', garply1: static Closure(): 'garply1', waldo1: static Closure(): 'waldo1', fred1: static Closure(): 'fred1', plugh1: static Closure(): 'plugh1', xyzzy1: static Closure(): 'xyzzy1', thud1: static Closure(): 'thud1', foo2: static Closure(): 'foo2', bar2: static Closure(): 'bar2', baz2: static Closure(): 'baz2'}", self::$map); + assertType("array{foo: static-Closure(): 'foo', bar: static-Closure(): 'bar', baz: static-Closure(): 'baz', qux: static-Closure(): 'qux', quux: static-Closure(): 'quux', corge: static-Closure(): 'corge', grault: static-Closure(): 'grault', garply: static-Closure(): 'garply', waldo: static-Closure(): 'waldo', fred: static-Closure(): 'fred', plugh: static-Closure(): 'plugh', xyzzy: static-Closure(): 'xyzzy', thud: static-Closure(): 'thud', foo1: static-Closure(): 'foo1', bar1: static-Closure(): 'bar1', baz1: static-Closure(): 'baz1', qux1: static-Closure(): 'qux1', quux1: static-Closure(): 'quux1', corge1: static-Closure(): 'corge1', grault1: static-Closure(): 'grault1', garply1: static-Closure(): 'garply1', waldo1: static-Closure(): 'waldo1', fred1: static-Closure(): 'fred1', plugh1: static-Closure(): 'plugh1', xyzzy1: static-Closure(): 'xyzzy1', thud1: static-Closure(): 'thud1', foo2: static-Closure(): 'foo2', bar2: static-Closure(): 'bar2', baz2: static-Closure(): 'baz2'}", self::$map); foreach (self::ADDITIONAL_MAPS as $map) { // added with 3 entries, breaching the closure limit of 32 entries diff --git a/tests/PHPStan/Analyser/nsrt/bug-7031.php b/tests/PHPStan/Analyser/nsrt/bug-7031.php index a561e9c6232..598dd24be92 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-7031.php +++ b/tests/PHPStan/Analyser/nsrt/bug-7031.php @@ -7,6 +7,6 @@ class SomeKey {} function () { - assertType('static Closure(int): Generator', static fn(int $value): iterable => yield new SomeKey); - assertType('static Closure(int): Generator', static function (int $value): iterable { yield new SomeKey; }); + assertType('static-Closure(int): Generator', static fn(int $value): iterable => yield new SomeKey); + assertType('static-Closure(int): Generator', static function (int $value): iterable { yield new SomeKey; }); }; diff --git a/tests/PHPStan/Analyser/nsrt/bug-9764.php b/tests/PHPStan/Analyser/nsrt/bug-9764.php index 73110fb4f7f..a75472594eb 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-9764.php +++ b/tests/PHPStan/Analyser/nsrt/bug-9764.php @@ -18,7 +18,7 @@ function (): void { /** @var array $a */ $a = []; $c = static fn (): array => $a; - assertType('static Closure(): array', $c); + assertType('static-Closure(): array', $c); $r = result($c); assertType('array', $r); diff --git a/tests/PHPStan/Analyser/nsrt/degrade-closures.php b/tests/PHPStan/Analyser/nsrt/degrade-closures.php index 66df7d08afd..a30e0e69b21 100644 --- a/tests/PHPStan/Analyser/nsrt/degrade-closures.php +++ b/tests/PHPStan/Analyser/nsrt/degrade-closures.php @@ -36,7 +36,7 @@ $arr[] = static function () {}; $arr[] = static function () {}; $arr[] = static function () {}; -assertType('array{static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void, static Closure(): void}', $arr); +assertType('array{static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void}', $arr); $arr[] = static function () {}; assertType('non-empty-list&oversized-array', $arr); diff --git a/tests/PHPStan/Type/ClosureTypeTest.php b/tests/PHPStan/Type/ClosureTypeTest.php index c1a01d37925..440d7cd17ea 100644 --- a/tests/PHPStan/Type/ClosureTypeTest.php +++ b/tests/PHPStan/Type/ClosureTypeTest.php @@ -194,12 +194,12 @@ public static function dataDescribe(): array 'static closure at precise' => [ new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createYes()), VerbosityLevel::precise(), - 'static Closure(): mixed', + 'static-Closure(): mixed', ], 'static closure at cache' => [ new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createYes()), VerbosityLevel::cache(), - 'static Closure(): mixed', + 'static-Closure(): mixed', ], 'non-static closure at precise' => [ new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createNo()), @@ -209,7 +209,7 @@ public static function dataDescribe(): array 'non-static closure at cache' => [ new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createNo()), VerbosityLevel::cache(), - 'non-static Closure(): mixed', + 'Closure(): mixed', ], 'maybe-static closure at precise' => [ new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createMaybe()), @@ -224,12 +224,12 @@ public static function dataDescribe(): array 'static common closure at precise' => [ new ClosureType(isStatic: TrinaryLogic::createYes()), VerbosityLevel::precise(), - 'static Closure', + 'static-Closure', ], 'static common closure at cache' => [ new ClosureType(isStatic: TrinaryLogic::createYes()), VerbosityLevel::cache(), - 'static Closure', + 'static-Closure', ], ]; } From 228377360a26d308943b3edbd4073c495137ea6e Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 19 May 2026 22:10:55 +0000 Subject: [PATCH 04/13] Replace `describeWithPrefix()` string param with `showPure`/`showStatic` booleans Co-Authored-By: Claude Opus 4.6 --- src/Type/ClosureType.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Type/ClosureType.php b/src/Type/ClosureType.php index 9085797a96b..cd65231ad90 100644 --- a/src/Type/ClosureType.php +++ b/src/Type/ClosureType.php @@ -280,15 +280,17 @@ public function describe(VerbosityLevel $level): string { return $level->handle( static fn (): string => 'Closure', - fn (): string => $this->describeWithPrefix(''), - fn (): string => $this->describeWithPrefix($this->isStatic->yes() ? 'static-' : ''), + fn (): string => $this->describeBody(showPure: true, showStatic: false), + fn (): string => $this->describeBody(showPure: true, showStatic: true), ); } - private function describeWithPrefix(string $prefix): string + private function describeBody(bool $showPure, bool $showStatic): string { + $prefix = $showStatic && $this->isStatic->yes() ? 'static-' : ''; + if ($this->isCommonCallable) { - $name = $this->isPure()->yes() ? 'pure-Closure' : 'Closure'; + $name = $showPure && $this->isPure()->yes() ? 'pure-Closure' : 'Closure'; return $prefix . $name; } From 9913df430e54928c01cc5fb1fd924fae55177d44 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 19 May 2026 22:21:35 +0000 Subject: [PATCH 05/13] Remove named parameters from describeBody() calls Co-Authored-By: Claude Opus 4.6 --- src/Type/ClosureType.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Type/ClosureType.php b/src/Type/ClosureType.php index cd65231ad90..e2a25985190 100644 --- a/src/Type/ClosureType.php +++ b/src/Type/ClosureType.php @@ -280,8 +280,8 @@ public function describe(VerbosityLevel $level): string { return $level->handle( static fn (): string => 'Closure', - fn (): string => $this->describeBody(showPure: true, showStatic: false), - fn (): string => $this->describeBody(showPure: true, showStatic: true), + fn (): string => $this->describeBody(true, false), + fn (): string => $this->describeBody(true, true), ); } From fcff80d9c65265eb87109ed09c8fe55607ff0bcd Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 19 May 2026 22:31:59 +0000 Subject: [PATCH 06/13] Update AttributeReflectionTest expectations for static closures in attributes Static closures in PHP 8.5 attributes now correctly describe as `static-Closure(...)` at precise verbosity level. Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Reflection/AttributeReflectionTest.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/PHPStan/Reflection/AttributeReflectionTest.php b/tests/PHPStan/Reflection/AttributeReflectionTest.php index 055f7343a21..c23fc3d485c 100644 --- a/tests/PHPStan/Reflection/AttributeReflectionTest.php +++ b/tests/PHPStan/Reflection/AttributeReflectionTest.php @@ -183,7 +183,7 @@ public static function dataAttributeReflections(): iterable [ 'ClosureInAttribute\\AttrWithCallback', [ - 'callback' => 'Closure(int): mixed', + 'callback' => 'static-Closure(int): mixed', ], ], ], @@ -194,7 +194,7 @@ public static function dataAttributeReflections(): iterable [ 'ClosureInAttribute\\AttrWithCallback', [ - 'callback' => 'Closure(int): mixed', + 'callback' => 'static-Closure(int): mixed', ], ], ], @@ -205,7 +205,7 @@ public static function dataAttributeReflections(): iterable [ 'ClosureInAttribute\\AttrWithCallback', [ - 'callback' => 'Closure(int): string', + 'callback' => 'static-Closure(int): string', ], ], ], @@ -219,7 +219,7 @@ public static function dataAttributeReflections(): iterable [ 'ClosureInAttribute\\AttrWithCallback2', [ - 'callback' => 'Closure(mixed): mixed', + 'callback' => 'static-Closure(mixed): mixed', ], ], ], @@ -230,7 +230,7 @@ public static function dataAttributeReflections(): iterable [ 'ClosureInAttribute\\AttrWithCallback2', [ - 'callback' => 'Closure(int=): mixed', + 'callback' => 'static-Closure(int=): mixed', ], ], ], @@ -241,7 +241,7 @@ public static function dataAttributeReflections(): iterable [ 'ClosureInAttribute\\AttrWithCallback2', [ - 'callback' => 'Closure(int ...): mixed', + 'callback' => 'static-Closure(int ...): mixed', ], ], ], From 0d6bbb7ae15b6d0aaa3d00e559e8e479677cbf69 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 25 May 2026 07:02:09 +0000 Subject: [PATCH 07/13] Add test cases for static-pure-Closure describe output Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Type/ClosureTypeTest.php | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/PHPStan/Type/ClosureTypeTest.php b/tests/PHPStan/Type/ClosureTypeTest.php index 440d7cd17ea..624bf7c1e11 100644 --- a/tests/PHPStan/Type/ClosureTypeTest.php +++ b/tests/PHPStan/Type/ClosureTypeTest.php @@ -231,6 +231,26 @@ public static function dataDescribe(): array VerbosityLevel::cache(), 'static-Closure', ], + 'static pure common closure at precise' => [ + new ClosureType(impurePoints: [], isStatic: TrinaryLogic::createYes()), + VerbosityLevel::precise(), + 'static-pure-Closure', + ], + 'static pure common closure at cache' => [ + new ClosureType(impurePoints: [], isStatic: TrinaryLogic::createYes()), + VerbosityLevel::cache(), + 'static-pure-Closure', + ], + 'static pure closure at precise' => [ + new ClosureType([], new MixedType(), false, impurePoints: [], isStatic: TrinaryLogic::createYes()), + VerbosityLevel::precise(), + 'static-Closure(): mixed', + ], + 'static pure closure at cache' => [ + new ClosureType([], new MixedType(), false, impurePoints: [], isStatic: TrinaryLogic::createYes()), + VerbosityLevel::cache(), + 'static-Closure(): mixed', + ], ]; } From 69c0da90c9e110dc6a08ce482655f568c38991bd Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 25 May 2026 07:07:36 +0000 Subject: [PATCH 08/13] Only show static- prefix for common callable closures in describe() Consistent with how pure- prefix is handled: the static- prefix only appears for common callable closures (e.g. static-Closure, static-pure-Closure), not for closures with explicit parameters (e.g. Closure(): void stays the same regardless of static-ness). Added tests for the static-pure-Closure combination. Co-Authored-By: Claude Opus 4.6 --- src/Type/ClosureType.php | 5 ++--- tests/PHPStan/Analyser/data/bug-14324.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-7031.php | 4 ++-- tests/PHPStan/Analyser/nsrt/bug-9764.php | 2 +- tests/PHPStan/Analyser/nsrt/closure-static-type.php | 10 +++++----- tests/PHPStan/Analyser/nsrt/degrade-closures.php | 2 +- .../PHPStan/Reflection/AttributeReflectionTest.php | 12 ++++++------ tests/PHPStan/Type/ClosureTypeTest.php | 13 +++++++++---- 8 files changed, 27 insertions(+), 23 deletions(-) diff --git a/src/Type/ClosureType.php b/src/Type/ClosureType.php index e2a25985190..433d6027a84 100644 --- a/src/Type/ClosureType.php +++ b/src/Type/ClosureType.php @@ -287,9 +287,8 @@ public function describe(VerbosityLevel $level): string private function describeBody(bool $showPure, bool $showStatic): string { - $prefix = $showStatic && $this->isStatic->yes() ? 'static-' : ''; - if ($this->isCommonCallable) { + $prefix = $showStatic && $this->isStatic->yes() ? 'static-' : ''; $name = $showPure && $this->isPure()->yes() ? 'pure-Closure' : 'Closure'; return $prefix . $name; } @@ -318,7 +317,7 @@ private function describeBody(bool $showPure, bool $showStatic): string $this->mustUseReturnValue, ); - return $prefix . $printer->print($selfWithoutParameterNames->toPhpDocNode()); + return $printer->print($selfWithoutParameterNames->toPhpDocNode()); } public function isOffsetAccessLegal(): TrinaryLogic diff --git a/tests/PHPStan/Analyser/data/bug-14324.php b/tests/PHPStan/Analyser/data/bug-14324.php index 37f7a1db2f7..20f97172fb9 100644 --- a/tests/PHPStan/Analyser/data/bug-14324.php +++ b/tests/PHPStan/Analyser/data/bug-14324.php @@ -50,7 +50,7 @@ public function createMap(): void 'bar2' => static fn() => 'bar2', 'baz2' => static fn() => 'baz2', ]; - assertType("array{foo: static-Closure(): 'foo', bar: static-Closure(): 'bar', baz: static-Closure(): 'baz', qux: static-Closure(): 'qux', quux: static-Closure(): 'quux', corge: static-Closure(): 'corge', grault: static-Closure(): 'grault', garply: static-Closure(): 'garply', waldo: static-Closure(): 'waldo', fred: static-Closure(): 'fred', plugh: static-Closure(): 'plugh', xyzzy: static-Closure(): 'xyzzy', thud: static-Closure(): 'thud', foo1: static-Closure(): 'foo1', bar1: static-Closure(): 'bar1', baz1: static-Closure(): 'baz1', qux1: static-Closure(): 'qux1', quux1: static-Closure(): 'quux1', corge1: static-Closure(): 'corge1', grault1: static-Closure(): 'grault1', garply1: static-Closure(): 'garply1', waldo1: static-Closure(): 'waldo1', fred1: static-Closure(): 'fred1', plugh1: static-Closure(): 'plugh1', xyzzy1: static-Closure(): 'xyzzy1', thud1: static-Closure(): 'thud1', foo2: static-Closure(): 'foo2', bar2: static-Closure(): 'bar2', baz2: static-Closure(): 'baz2'}", self::$map); + assertType("array{foo: Closure(): 'foo', bar: Closure(): 'bar', baz: Closure(): 'baz', qux: Closure(): 'qux', quux: Closure(): 'quux', corge: Closure(): 'corge', grault: Closure(): 'grault', garply: Closure(): 'garply', waldo: Closure(): 'waldo', fred: Closure(): 'fred', plugh: Closure(): 'plugh', xyzzy: Closure(): 'xyzzy', thud: Closure(): 'thud', foo1: Closure(): 'foo1', bar1: Closure(): 'bar1', baz1: Closure(): 'baz1', qux1: Closure(): 'qux1', quux1: Closure(): 'quux1', corge1: Closure(): 'corge1', grault1: Closure(): 'grault1', garply1: Closure(): 'garply1', waldo1: Closure(): 'waldo1', fred1: Closure(): 'fred1', plugh1: Closure(): 'plugh1', xyzzy1: Closure(): 'xyzzy1', thud1: Closure(): 'thud1', foo2: Closure(): 'foo2', bar2: Closure(): 'bar2', baz2: Closure(): 'baz2'}", self::$map); foreach (self::ADDITIONAL_MAPS as $map) { // added with 3 entries, breaching the closure limit of 32 entries diff --git a/tests/PHPStan/Analyser/nsrt/bug-7031.php b/tests/PHPStan/Analyser/nsrt/bug-7031.php index 598dd24be92..a325a67d1f2 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-7031.php +++ b/tests/PHPStan/Analyser/nsrt/bug-7031.php @@ -7,6 +7,6 @@ class SomeKey {} function () { - assertType('static-Closure(int): Generator', static fn(int $value): iterable => yield new SomeKey); - assertType('static-Closure(int): Generator', static function (int $value): iterable { yield new SomeKey; }); + assertType('Closure(int): Generator', static fn(int $value): iterable => yield new SomeKey); + assertType('Closure(int): Generator', static function (int $value): iterable { yield new SomeKey; }); }; diff --git a/tests/PHPStan/Analyser/nsrt/bug-9764.php b/tests/PHPStan/Analyser/nsrt/bug-9764.php index a75472594eb..f24b810fe8d 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-9764.php +++ b/tests/PHPStan/Analyser/nsrt/bug-9764.php @@ -18,7 +18,7 @@ function (): void { /** @var array $a */ $a = []; $c = static fn (): array => $a; - assertType('static-Closure(): array', $c); + assertType('Closure(): array', $c); $r = result($c); assertType('array', $r); diff --git a/tests/PHPStan/Analyser/nsrt/closure-static-type.php b/tests/PHPStan/Analyser/nsrt/closure-static-type.php index 6b84bd967df..a711585c2b3 100644 --- a/tests/PHPStan/Analyser/nsrt/closure-static-type.php +++ b/tests/PHPStan/Analyser/nsrt/closure-static-type.php @@ -11,13 +11,13 @@ final class Foo public function doFoo(): void { $static = static function (): void {}; - assertType('static-Closure(): void', $static); + assertType('Closure(): void', $static); $nonStatic = function (): void {}; assertType('Closure(): void', $nonStatic); $staticArrow = static fn (): int => 1; - assertType('static-Closure(): 1', $staticArrow); + assertType('Closure(): 1', $staticArrow); $nonStaticArrow = fn (): int => 1; assertType('Closure(): 1', $nonStaticArrow); @@ -26,7 +26,7 @@ public function doFoo(): void public function doBindTo(): void { $static = static function (): void {}; - assertType('static-Closure(): void', $static->bindTo($this)); + assertType('Closure(): void', $static->bindTo($this)); $nonStatic = function (): void {}; assertType('Closure(): void', $nonStatic->bindTo($this)); @@ -35,7 +35,7 @@ public function doBindTo(): void public function doBind(): void { $static = static function (): void {}; - assertType('static-Closure(): void', Closure::bind($static, $this)); + assertType('Closure(): void', Closure::bind($static, $this)); $nonStatic = function (): void {}; assertType('Closure(): void', Closure::bind($nonStatic, $this)); @@ -53,7 +53,7 @@ public function doUnknown(Closure $unknownClosure): void public function doFromCallable(): void { $fn = Closure::fromCallable(static function (): void {}); - assertType('static-Closure(): void', $fn->bindTo($this)); + assertType('Closure(): void', $fn->bindTo($this)); $fn2 = Closure::fromCallable(function (): void {}); assertType('Closure(): void', $fn2->bindTo($this)); diff --git a/tests/PHPStan/Analyser/nsrt/degrade-closures.php b/tests/PHPStan/Analyser/nsrt/degrade-closures.php index a30e0e69b21..a7be72480e7 100644 --- a/tests/PHPStan/Analyser/nsrt/degrade-closures.php +++ b/tests/PHPStan/Analyser/nsrt/degrade-closures.php @@ -36,7 +36,7 @@ $arr[] = static function () {}; $arr[] = static function () {}; $arr[] = static function () {}; -assertType('array{static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void}', $arr); +assertType('array{Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void}', $arr); $arr[] = static function () {}; assertType('non-empty-list&oversized-array', $arr); diff --git a/tests/PHPStan/Reflection/AttributeReflectionTest.php b/tests/PHPStan/Reflection/AttributeReflectionTest.php index c23fc3d485c..055f7343a21 100644 --- a/tests/PHPStan/Reflection/AttributeReflectionTest.php +++ b/tests/PHPStan/Reflection/AttributeReflectionTest.php @@ -183,7 +183,7 @@ public static function dataAttributeReflections(): iterable [ 'ClosureInAttribute\\AttrWithCallback', [ - 'callback' => 'static-Closure(int): mixed', + 'callback' => 'Closure(int): mixed', ], ], ], @@ -194,7 +194,7 @@ public static function dataAttributeReflections(): iterable [ 'ClosureInAttribute\\AttrWithCallback', [ - 'callback' => 'static-Closure(int): mixed', + 'callback' => 'Closure(int): mixed', ], ], ], @@ -205,7 +205,7 @@ public static function dataAttributeReflections(): iterable [ 'ClosureInAttribute\\AttrWithCallback', [ - 'callback' => 'static-Closure(int): string', + 'callback' => 'Closure(int): string', ], ], ], @@ -219,7 +219,7 @@ public static function dataAttributeReflections(): iterable [ 'ClosureInAttribute\\AttrWithCallback2', [ - 'callback' => 'static-Closure(mixed): mixed', + 'callback' => 'Closure(mixed): mixed', ], ], ], @@ -230,7 +230,7 @@ public static function dataAttributeReflections(): iterable [ 'ClosureInAttribute\\AttrWithCallback2', [ - 'callback' => 'static-Closure(int=): mixed', + 'callback' => 'Closure(int=): mixed', ], ], ], @@ -241,7 +241,7 @@ public static function dataAttributeReflections(): iterable [ 'ClosureInAttribute\\AttrWithCallback2', [ - 'callback' => 'static-Closure(int ...): mixed', + 'callback' => 'Closure(int ...): mixed', ], ], ], diff --git a/tests/PHPStan/Type/ClosureTypeTest.php b/tests/PHPStan/Type/ClosureTypeTest.php index 624bf7c1e11..0bc62f1e814 100644 --- a/tests/PHPStan/Type/ClosureTypeTest.php +++ b/tests/PHPStan/Type/ClosureTypeTest.php @@ -194,12 +194,12 @@ public static function dataDescribe(): array 'static closure at precise' => [ new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createYes()), VerbosityLevel::precise(), - 'static-Closure(): mixed', + 'Closure(): mixed', ], 'static closure at cache' => [ new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createYes()), VerbosityLevel::cache(), - 'static-Closure(): mixed', + 'Closure(): mixed', ], 'non-static closure at precise' => [ new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createNo()), @@ -244,12 +244,17 @@ public static function dataDescribe(): array 'static pure closure at precise' => [ new ClosureType([], new MixedType(), false, impurePoints: [], isStatic: TrinaryLogic::createYes()), VerbosityLevel::precise(), - 'static-Closure(): mixed', + 'Closure(): mixed', ], 'static pure closure at cache' => [ new ClosureType([], new MixedType(), false, impurePoints: [], isStatic: TrinaryLogic::createYes()), VerbosityLevel::cache(), - 'static-Closure(): mixed', + 'Closure(): mixed', + ], + 'static pure common closure at value' => [ + new ClosureType(impurePoints: [], isStatic: TrinaryLogic::createYes()), + VerbosityLevel::value(), + 'pure-Closure', ], ]; } From afda50d2e34666a528f76b3a1415029ce4cb5fef Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 25 May 2026 07:46:02 +0000 Subject: [PATCH 09/13] Revert describeBody() extraction, inline static- prefix in isCommonCallable branch Co-Authored-By: Claude Opus 4.6 --- src/Type/ClosureType.php | 97 +++++++++++++++++++++++++--------------- 1 file changed, 62 insertions(+), 35 deletions(-) diff --git a/src/Type/ClosureType.php b/src/Type/ClosureType.php index 433d6027a84..1e7a321d807 100644 --- a/src/Type/ClosureType.php +++ b/src/Type/ClosureType.php @@ -280,44 +280,71 @@ public function describe(VerbosityLevel $level): string { return $level->handle( static fn (): string => 'Closure', - fn (): string => $this->describeBody(true, false), - fn (): string => $this->describeBody(true, true), - ); - } + function (): string { + if ($this->isCommonCallable) { + return $this->isPure()->yes() ? 'pure-Closure' : 'Closure'; + } + + $printer = new Printer(); + $selfWithoutParameterNames = new self( + array_map(static fn (ParameterReflection $p): ParameterReflection => new DummyParameter( + '', + $p->getType(), + optional: $p->isOptional() && !$p->isVariadic(), + passedByReference: PassedByReference::createNo(), + variadic: $p->isVariadic(), + defaultValue: $p->getDefaultValue(), + ), $this->parameters), + $this->returnType, + $this->variadic, + $this->templateTypeMap, + $this->resolvedTemplateTypeMap, + $this->callSiteVarianceMap, + $this->templateTags, + $this->throwPoints, + $this->impurePoints, + $this->invalidateExpressions, + $this->usedVariables, + $this->acceptsNamedArguments, + $this->mustUseReturnValue, + ); - private function describeBody(bool $showPure, bool $showStatic): string - { - if ($this->isCommonCallable) { - $prefix = $showStatic && $this->isStatic->yes() ? 'static-' : ''; - $name = $showPure && $this->isPure()->yes() ? 'pure-Closure' : 'Closure'; - return $prefix . $name; - } + return $printer->print($selfWithoutParameterNames->toPhpDocNode()); + }, + function (): string { + if ($this->isCommonCallable) { + $prefix = $this->isStatic->yes() ? 'static-' : ''; + $name = $this->isPure()->yes() ? 'pure-Closure' : 'Closure'; + return $prefix . $name; + } + + $printer = new Printer(); + $selfWithoutParameterNames = new self( + array_map(static fn (ParameterReflection $p): ParameterReflection => new DummyParameter( + '', + $p->getType(), + optional: $p->isOptional() && !$p->isVariadic(), + passedByReference: PassedByReference::createNo(), + variadic: $p->isVariadic(), + defaultValue: $p->getDefaultValue(), + ), $this->parameters), + $this->returnType, + $this->variadic, + $this->templateTypeMap, + $this->resolvedTemplateTypeMap, + $this->callSiteVarianceMap, + $this->templateTags, + $this->throwPoints, + $this->impurePoints, + $this->invalidateExpressions, + $this->usedVariables, + $this->acceptsNamedArguments, + $this->mustUseReturnValue, + ); - $printer = new Printer(); - $selfWithoutParameterNames = new self( - array_map(static fn (ParameterReflection $p): ParameterReflection => new DummyParameter( - '', - $p->getType(), - optional: $p->isOptional() && !$p->isVariadic(), - passedByReference: PassedByReference::createNo(), - variadic: $p->isVariadic(), - defaultValue: $p->getDefaultValue(), - ), $this->parameters), - $this->returnType, - $this->variadic, - $this->templateTypeMap, - $this->resolvedTemplateTypeMap, - $this->callSiteVarianceMap, - $this->templateTags, - $this->throwPoints, - $this->impurePoints, - $this->invalidateExpressions, - $this->usedVariables, - $this->acceptsNamedArguments, - $this->mustUseReturnValue, + return $printer->print($selfWithoutParameterNames->toPhpDocNode()); + }, ); - - return $printer->print($selfWithoutParameterNames->toPhpDocNode()); } public function isOffsetAccessLegal(): TrinaryLogic From 9486a3f570159882390ae10c4a2bbe243517e039 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 25 May 2026 09:59:39 +0200 Subject: [PATCH 10/13] simplify --- src/Type/ClosureType.php | 31 -------------------------- tests/PHPStan/Type/ClosureTypeTest.php | 2 +- 2 files changed, 1 insertion(+), 32 deletions(-) diff --git a/src/Type/ClosureType.php b/src/Type/ClosureType.php index 1e7a321d807..1fbd993eb64 100644 --- a/src/Type/ClosureType.php +++ b/src/Type/ClosureType.php @@ -280,37 +280,6 @@ public function describe(VerbosityLevel $level): string { return $level->handle( static fn (): string => 'Closure', - function (): string { - if ($this->isCommonCallable) { - return $this->isPure()->yes() ? 'pure-Closure' : 'Closure'; - } - - $printer = new Printer(); - $selfWithoutParameterNames = new self( - array_map(static fn (ParameterReflection $p): ParameterReflection => new DummyParameter( - '', - $p->getType(), - optional: $p->isOptional() && !$p->isVariadic(), - passedByReference: PassedByReference::createNo(), - variadic: $p->isVariadic(), - defaultValue: $p->getDefaultValue(), - ), $this->parameters), - $this->returnType, - $this->variadic, - $this->templateTypeMap, - $this->resolvedTemplateTypeMap, - $this->callSiteVarianceMap, - $this->templateTags, - $this->throwPoints, - $this->impurePoints, - $this->invalidateExpressions, - $this->usedVariables, - $this->acceptsNamedArguments, - $this->mustUseReturnValue, - ); - - return $printer->print($selfWithoutParameterNames->toPhpDocNode()); - }, function (): string { if ($this->isCommonCallable) { $prefix = $this->isStatic->yes() ? 'static-' : ''; diff --git a/tests/PHPStan/Type/ClosureTypeTest.php b/tests/PHPStan/Type/ClosureTypeTest.php index 0bc62f1e814..ad884df7cf4 100644 --- a/tests/PHPStan/Type/ClosureTypeTest.php +++ b/tests/PHPStan/Type/ClosureTypeTest.php @@ -254,7 +254,7 @@ public static function dataDescribe(): array 'static pure common closure at value' => [ new ClosureType(impurePoints: [], isStatic: TrinaryLogic::createYes()), VerbosityLevel::value(), - 'pure-Closure', + 'static-pure-Closure', ], ]; } From c5c6d02c1627ec9bd333fd6a7cdc8a5b4da81632 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 27 May 2026 09:14:37 +0000 Subject: [PATCH 11/13] Show static- prefix in describe() at precise/cache levels, propagate isStatic - Add precise callback to ClosureType::describe() that shows static- prefix for all closures (not just common callable) when isStatic is yes - Extract describeCallable() private method to avoid duplication - Add static- prefix to toPhpDocNode() for common callable closures - Add static-Closure/static-pure-Closure parsing in TypeNodeResolver - Pass isStatic to new self() in describe() and to all relevant new ClosureType construction sites (InitializerExprTypeResolver, ClosureTypeFactory) - Update getRecommendedLevelByType to bump to precise level when a ClosureType has definite isStatic (yes or no) Co-Authored-By: Claude Opus 4.6 --- src/PhpDoc/TypeNodeResolver.php | 6 ++ .../InitializerExprTypeResolver.php | 3 + src/Type/ClosureType.php | 69 ++++++++++++------- src/Type/ClosureTypeFactory.php | 3 +- src/Type/VerbosityLevel.php | 5 ++ 5 files changed, 60 insertions(+), 26 deletions(-) diff --git a/src/PhpDoc/TypeNodeResolver.php b/src/PhpDoc/TypeNodeResolver.php index c9194768d4c..c8dbd98cfd5 100644 --- a/src/PhpDoc/TypeNodeResolver.php +++ b/src/PhpDoc/TypeNodeResolver.php @@ -400,6 +400,12 @@ private function resolveIdentifierTypeNode(IdentifierTypeNode $typeNode, NameSco case 'pure-closure': return ClosureType::createPure(); + case 'static-closure': + return new ClosureType(isStatic: TrinaryLogic::createYes()); + + case 'static-pure-closure': + return new ClosureType(impurePoints: [], isStatic: TrinaryLogic::createYes()); + case 'resource': $type = $this->tryResolvePseudoTypeClassType($typeNode, $nameScope); diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index 82431b65414..fa1d0183723 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -929,11 +929,13 @@ public function createFirstClassCallable( $impurePoints = []; $acceptsNamedArguments = TrinaryLogic::createYes(); $mustUseReturnValue = TrinaryLogic::createMaybe(); + $isStaticClosure = TrinaryLogic::createMaybe(); if ($variant instanceof CallableParametersAcceptor) { $throwPoints = $variant->getThrowPoints(); $impurePoints = $variant->getImpurePoints(); $acceptsNamedArguments = $variant->acceptsNamedArguments(); $mustUseReturnValue = $variant->mustUseReturnValue(); + $isStaticClosure = $variant->isStaticClosure(); } elseif ($function !== null) { $returnTypeForThrow = $variant->getReturnType(); $throwType = $function->getThrowType(); @@ -977,6 +979,7 @@ public function createFirstClassCallable( acceptsNamedArguments: $acceptsNamedArguments, mustUseReturnValue: $mustUseReturnValue, assertions: $assertions, + isStatic: $isStaticClosure, ); } diff --git a/src/Type/ClosureType.php b/src/Type/ClosureType.php index 1fbd993eb64..b496f17d936 100644 --- a/src/Type/ClosureType.php +++ b/src/Type/ClosureType.php @@ -287,35 +287,52 @@ function (): string { return $prefix . $name; } - $printer = new Printer(); - $selfWithoutParameterNames = new self( - array_map(static fn (ParameterReflection $p): ParameterReflection => new DummyParameter( - '', - $p->getType(), - optional: $p->isOptional() && !$p->isVariadic(), - passedByReference: PassedByReference::createNo(), - variadic: $p->isVariadic(), - defaultValue: $p->getDefaultValue(), - ), $this->parameters), - $this->returnType, - $this->variadic, - $this->templateTypeMap, - $this->resolvedTemplateTypeMap, - $this->callSiteVarianceMap, - $this->templateTags, - $this->throwPoints, - $this->impurePoints, - $this->invalidateExpressions, - $this->usedVariables, - $this->acceptsNamedArguments, - $this->mustUseReturnValue, - ); + return $this->describeCallable(); + }, + function (): string { + $prefix = $this->isStatic->yes() ? 'static-' : ''; - return $printer->print($selfWithoutParameterNames->toPhpDocNode()); + if ($this->isCommonCallable) { + $name = $this->isPure()->yes() ? 'pure-Closure' : 'Closure'; + return $prefix . $name; + } + + return $prefix . $this->describeCallable(); }, ); } + private function describeCallable(): string + { + $printer = new Printer(); + $selfWithoutParameterNames = new self( + array_map(static fn (ParameterReflection $p): ParameterReflection => new DummyParameter( + '', + $p->getType(), + optional: $p->isOptional() && !$p->isVariadic(), + passedByReference: PassedByReference::createNo(), + variadic: $p->isVariadic(), + defaultValue: $p->getDefaultValue(), + ), $this->parameters), + $this->returnType, + $this->variadic, + $this->templateTypeMap, + $this->resolvedTemplateTypeMap, + $this->callSiteVarianceMap, + $this->templateTags, + $this->throwPoints, + $this->impurePoints, + $this->invalidateExpressions, + $this->usedVariables, + $this->acceptsNamedArguments, + $this->mustUseReturnValue, + $this->assertions, + $this->isStatic, + ); + + return $printer->print($selfWithoutParameterNames->toPhpDocNode()); + } + public function isOffsetAccessLegal(): TrinaryLogic { return TrinaryLogic::createNo(); @@ -906,7 +923,9 @@ public function getFiniteTypes(): array public function toPhpDocNode(): TypeNode { if ($this->isCommonCallable) { - return new IdentifierTypeNode($this->isPure()->yes() ? 'pure-Closure' : 'Closure'); + $prefix = $this->isStatic->yes() ? 'static-' : ''; + $name = $this->isPure()->yes() ? 'pure-Closure' : 'Closure'; + return new IdentifierTypeNode($prefix . $name); } $parameters = []; diff --git a/src/Type/ClosureTypeFactory.php b/src/Type/ClosureTypeFactory.php index 4390073565a..8dd1b22ed12 100644 --- a/src/Type/ClosureTypeFactory.php +++ b/src/Type/ClosureTypeFactory.php @@ -15,6 +15,7 @@ use PHPStan\BetterReflection\SourceLocator\SourceStubber\ReflectionSourceStubber; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\TrinaryLogic; use PHPStan\Reflection\InitializerExprContext; use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\Reflection\ParameterReflection; @@ -136,7 +137,7 @@ public function getDefaultValue(): ?Type } } - return new ClosureType($parameters, TypehintHelper::decideTypeFromReflection(ReflectionType::fromTypeOrNull($betterReflectionFunction->getReturnType()), selfClass: $selfClass), $betterReflectionFunction->isVariadic()); + return new ClosureType($parameters, TypehintHelper::decideTypeFromReflection(ReflectionType::fromTypeOrNull($betterReflectionFunction->getReturnType()), selfClass: $selfClass), $betterReflectionFunction->isVariadic(), isStatic: TrinaryLogic::createFromBoolean($closureReflectionFunction->isStatic())); } } diff --git a/src/Type/VerbosityLevel.php b/src/Type/VerbosityLevel.php index 32be9683a81..228ef04508d 100644 --- a/src/Type/VerbosityLevel.php +++ b/src/Type/VerbosityLevel.php @@ -137,6 +137,11 @@ public static function getRecommendedLevelByType(Type $acceptingType, ?Type $acc if ($type->isCallable()->yes()) { $moreVerbose = true; + if ($type instanceof ClosureType && !$type->isStaticClosure()->maybe()) { + $veryVerbose = true; + return $type; + } + // Keep checking if we need to be very verbose. return $traverse($type); } From 94cce399b4e5566a9472a53e82e3a00e7ca56b7e Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 27 May 2026 09:14:47 +0000 Subject: [PATCH 12/13] Add accepts/TypeCombinator/toPhpDocNode tests, update expectations for static- prefix - Add dataAccepts/testAccepts to ClosureTypeTest with static/non-static/ maybe-static combinations - Add TypeCombinator::union and ::intersect tests for static closures - Add toPhpDocNode tests for static-Closure and static-pure-Closure - Update existing test expectations to include static- prefix at precise level for static closures in closure-static-type, bug-7031, bug-9764, degrade-closures, bug-14324 Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/data/bug-14324.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-7031.php | 4 +- tests/PHPStan/Analyser/nsrt/bug-9764.php | 2 +- .../Analyser/nsrt/closure-static-type.php | 10 +-- .../Analyser/nsrt/degrade-closures.php | 2 +- tests/PHPStan/Type/ClosureTypeTest.php | 69 +++++++++++++++++-- tests/PHPStan/Type/TypeCombinatorTest.php | 64 +++++++++++++++++ tests/PHPStan/Type/TypeToPhpDocNodeTest.php | 20 ++++++ 8 files changed, 159 insertions(+), 14 deletions(-) diff --git a/tests/PHPStan/Analyser/data/bug-14324.php b/tests/PHPStan/Analyser/data/bug-14324.php index 20f97172fb9..37f7a1db2f7 100644 --- a/tests/PHPStan/Analyser/data/bug-14324.php +++ b/tests/PHPStan/Analyser/data/bug-14324.php @@ -50,7 +50,7 @@ public function createMap(): void 'bar2' => static fn() => 'bar2', 'baz2' => static fn() => 'baz2', ]; - assertType("array{foo: Closure(): 'foo', bar: Closure(): 'bar', baz: Closure(): 'baz', qux: Closure(): 'qux', quux: Closure(): 'quux', corge: Closure(): 'corge', grault: Closure(): 'grault', garply: Closure(): 'garply', waldo: Closure(): 'waldo', fred: Closure(): 'fred', plugh: Closure(): 'plugh', xyzzy: Closure(): 'xyzzy', thud: Closure(): 'thud', foo1: Closure(): 'foo1', bar1: Closure(): 'bar1', baz1: Closure(): 'baz1', qux1: Closure(): 'qux1', quux1: Closure(): 'quux1', corge1: Closure(): 'corge1', grault1: Closure(): 'grault1', garply1: Closure(): 'garply1', waldo1: Closure(): 'waldo1', fred1: Closure(): 'fred1', plugh1: Closure(): 'plugh1', xyzzy1: Closure(): 'xyzzy1', thud1: Closure(): 'thud1', foo2: Closure(): 'foo2', bar2: Closure(): 'bar2', baz2: Closure(): 'baz2'}", self::$map); + assertType("array{foo: static-Closure(): 'foo', bar: static-Closure(): 'bar', baz: static-Closure(): 'baz', qux: static-Closure(): 'qux', quux: static-Closure(): 'quux', corge: static-Closure(): 'corge', grault: static-Closure(): 'grault', garply: static-Closure(): 'garply', waldo: static-Closure(): 'waldo', fred: static-Closure(): 'fred', plugh: static-Closure(): 'plugh', xyzzy: static-Closure(): 'xyzzy', thud: static-Closure(): 'thud', foo1: static-Closure(): 'foo1', bar1: static-Closure(): 'bar1', baz1: static-Closure(): 'baz1', qux1: static-Closure(): 'qux1', quux1: static-Closure(): 'quux1', corge1: static-Closure(): 'corge1', grault1: static-Closure(): 'grault1', garply1: static-Closure(): 'garply1', waldo1: static-Closure(): 'waldo1', fred1: static-Closure(): 'fred1', plugh1: static-Closure(): 'plugh1', xyzzy1: static-Closure(): 'xyzzy1', thud1: static-Closure(): 'thud1', foo2: static-Closure(): 'foo2', bar2: static-Closure(): 'bar2', baz2: static-Closure(): 'baz2'}", self::$map); foreach (self::ADDITIONAL_MAPS as $map) { // added with 3 entries, breaching the closure limit of 32 entries diff --git a/tests/PHPStan/Analyser/nsrt/bug-7031.php b/tests/PHPStan/Analyser/nsrt/bug-7031.php index a325a67d1f2..598dd24be92 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-7031.php +++ b/tests/PHPStan/Analyser/nsrt/bug-7031.php @@ -7,6 +7,6 @@ class SomeKey {} function () { - assertType('Closure(int): Generator', static fn(int $value): iterable => yield new SomeKey); - assertType('Closure(int): Generator', static function (int $value): iterable { yield new SomeKey; }); + assertType('static-Closure(int): Generator', static fn(int $value): iterable => yield new SomeKey); + assertType('static-Closure(int): Generator', static function (int $value): iterable { yield new SomeKey; }); }; diff --git a/tests/PHPStan/Analyser/nsrt/bug-9764.php b/tests/PHPStan/Analyser/nsrt/bug-9764.php index f24b810fe8d..a75472594eb 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-9764.php +++ b/tests/PHPStan/Analyser/nsrt/bug-9764.php @@ -18,7 +18,7 @@ function (): void { /** @var array $a */ $a = []; $c = static fn (): array => $a; - assertType('Closure(): array', $c); + assertType('static-Closure(): array', $c); $r = result($c); assertType('array', $r); diff --git a/tests/PHPStan/Analyser/nsrt/closure-static-type.php b/tests/PHPStan/Analyser/nsrt/closure-static-type.php index a711585c2b3..6b84bd967df 100644 --- a/tests/PHPStan/Analyser/nsrt/closure-static-type.php +++ b/tests/PHPStan/Analyser/nsrt/closure-static-type.php @@ -11,13 +11,13 @@ final class Foo public function doFoo(): void { $static = static function (): void {}; - assertType('Closure(): void', $static); + assertType('static-Closure(): void', $static); $nonStatic = function (): void {}; assertType('Closure(): void', $nonStatic); $staticArrow = static fn (): int => 1; - assertType('Closure(): 1', $staticArrow); + assertType('static-Closure(): 1', $staticArrow); $nonStaticArrow = fn (): int => 1; assertType('Closure(): 1', $nonStaticArrow); @@ -26,7 +26,7 @@ public function doFoo(): void public function doBindTo(): void { $static = static function (): void {}; - assertType('Closure(): void', $static->bindTo($this)); + assertType('static-Closure(): void', $static->bindTo($this)); $nonStatic = function (): void {}; assertType('Closure(): void', $nonStatic->bindTo($this)); @@ -35,7 +35,7 @@ public function doBindTo(): void public function doBind(): void { $static = static function (): void {}; - assertType('Closure(): void', Closure::bind($static, $this)); + assertType('static-Closure(): void', Closure::bind($static, $this)); $nonStatic = function (): void {}; assertType('Closure(): void', Closure::bind($nonStatic, $this)); @@ -53,7 +53,7 @@ public function doUnknown(Closure $unknownClosure): void public function doFromCallable(): void { $fn = Closure::fromCallable(static function (): void {}); - assertType('Closure(): void', $fn->bindTo($this)); + assertType('static-Closure(): void', $fn->bindTo($this)); $fn2 = Closure::fromCallable(function (): void {}); assertType('Closure(): void', $fn2->bindTo($this)); diff --git a/tests/PHPStan/Analyser/nsrt/degrade-closures.php b/tests/PHPStan/Analyser/nsrt/degrade-closures.php index a7be72480e7..a30e0e69b21 100644 --- a/tests/PHPStan/Analyser/nsrt/degrade-closures.php +++ b/tests/PHPStan/Analyser/nsrt/degrade-closures.php @@ -36,7 +36,7 @@ $arr[] = static function () {}; $arr[] = static function () {}; $arr[] = static function () {}; -assertType('array{Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void, Closure(): void}', $arr); +assertType('array{static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void, static-Closure(): void}', $arr); $arr[] = static function () {}; assertType('non-empty-list&oversized-array', $arr); diff --git a/tests/PHPStan/Type/ClosureTypeTest.php b/tests/PHPStan/Type/ClosureTypeTest.php index ad884df7cf4..9889e0ed697 100644 --- a/tests/PHPStan/Type/ClosureTypeTest.php +++ b/tests/PHPStan/Type/ClosureTypeTest.php @@ -194,12 +194,12 @@ public static function dataDescribe(): array 'static closure at precise' => [ new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createYes()), VerbosityLevel::precise(), - 'Closure(): mixed', + 'static-Closure(): mixed', ], 'static closure at cache' => [ new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createYes()), VerbosityLevel::cache(), - 'Closure(): mixed', + 'static-Closure(): mixed', ], 'non-static closure at precise' => [ new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createNo()), @@ -244,12 +244,12 @@ public static function dataDescribe(): array 'static pure closure at precise' => [ new ClosureType([], new MixedType(), false, impurePoints: [], isStatic: TrinaryLogic::createYes()), VerbosityLevel::precise(), - 'Closure(): mixed', + 'static-Closure(): mixed', ], 'static pure closure at cache' => [ new ClosureType([], new MixedType(), false, impurePoints: [], isStatic: TrinaryLogic::createYes()), VerbosityLevel::cache(), - 'Closure(): mixed', + 'static-Closure(): mixed', ], 'static pure common closure at value' => [ new ClosureType(impurePoints: [], isStatic: TrinaryLogic::createYes()), @@ -269,6 +269,67 @@ public function testDescribe( $this->assertSame($expectedDescription, $type->describe($level)); } + public static function dataAccepts(): array + { + return [ + 'static closure accepts static closure' => [ + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createYes()), + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createYes()), + TrinaryLogic::createYes(), + ], + 'static closure does not accept non-static closure' => [ + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createYes()), + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createNo()), + TrinaryLogic::createNo(), + ], + 'non-static closure does not accept static closure' => [ + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createNo()), + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createYes()), + TrinaryLogic::createNo(), + ], + 'non-static closure accepts non-static closure' => [ + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createNo()), + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createNo()), + TrinaryLogic::createYes(), + ], + 'maybe-static closure accepts static closure' => [ + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createMaybe()), + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createYes()), + TrinaryLogic::createYes(), + ], + 'maybe-static closure accepts non-static closure' => [ + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createMaybe()), + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createNo()), + TrinaryLogic::createYes(), + ], + 'static closure maybe accepts maybe-static closure' => [ + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createYes()), + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createMaybe()), + TrinaryLogic::createMaybe(), + ], + 'non-static closure maybe accepts maybe-static closure' => [ + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createNo()), + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createMaybe()), + TrinaryLogic::createMaybe(), + ], + ]; + } + + #[DataProvider('dataAccepts')] + public function testAccepts( + Type $type, + Type $otherType, + TrinaryLogic $expectedResult, + ): void + { + $actualResult = $type->accepts($otherType, true); + $this->assertSame( + $expectedResult->describe(), + $actualResult->result->describe(), + sprintf('%s -> accepts(%s)', $type->describe(VerbosityLevel::precise()), $otherType->describe(VerbosityLevel::precise())), + ); + } + public function testIsStaticClosure(): void { $staticClosure = new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createYes()); diff --git a/tests/PHPStan/Type/TypeCombinatorTest.php b/tests/PHPStan/Type/TypeCombinatorTest.php index f325c638da4..56926bebc3b 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -2771,6 +2771,38 @@ public static function dataUnion(): iterable ClosureType::class, 'Closure(): mixed', ]; + yield 'union of static and maybe-static closure' => [ + [ + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createYes()), + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createMaybe()), + ], + ClosureType::class, + 'Closure(): mixed', + ]; + yield 'union of non-static and maybe-static closure' => [ + [ + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createNo()), + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createMaybe()), + ], + ClosureType::class, + 'Closure(): mixed', + ]; + yield 'union of two static closures' => [ + [ + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createYes()), + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createYes()), + ], + ClosureType::class, + 'static-Closure(): mixed', + ]; + yield 'union of static common closure and static common closure' => [ + [ + new ClosureType(isStatic: TrinaryLogic::createYes()), + new ClosureType(isStatic: TrinaryLogic::createYes()), + ], + ClosureType::class, + 'static-Closure', + ]; yield [ [ new IntersectionType([ @@ -4799,6 +4831,38 @@ public static function dataIntersect(): iterable ClosureType::class, 'pure-Closure', ]; + yield 'intersect of static and non-static closure' => [ + [ + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createYes()), + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createNo()), + ], + NeverType::class, + '*NEVER*=implicit', + ]; + yield 'intersect of static and maybe-static closure' => [ + [ + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createYes()), + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createMaybe()), + ], + ClosureType::class, + 'static-Closure(): mixed', + ]; + yield 'intersect of non-static and maybe-static closure' => [ + [ + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createNo()), + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createMaybe()), + ], + ClosureType::class, + 'Closure(): mixed', + ]; + yield 'intersect of two static closures' => [ + [ + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createYes()), + new ClosureType([], new MixedType(), false, isStatic: TrinaryLogic::createYes()), + ], + ClosureType::class, + 'static-Closure(): mixed', + ]; $xy = new ConstantArrayType([ new ConstantIntegerType(0), diff --git a/tests/PHPStan/Type/TypeToPhpDocNodeTest.php b/tests/PHPStan/Type/TypeToPhpDocNodeTest.php index 3cf9e5f3fa6..782b8ccfb53 100644 --- a/tests/PHPStan/Type/TypeToPhpDocNodeTest.php +++ b/tests/PHPStan/Type/TypeToPhpDocNodeTest.php @@ -423,6 +423,21 @@ public static function dataToPhpDocNode(): iterable ]), 'non-empty-array{0?: string, 1?: string, 2?: string, 3?: string}', ]; + + yield [ + new ClosureType(isStatic: TrinaryLogic::createYes()), + 'static-Closure', + ]; + + yield [ + new ClosureType(impurePoints: [], isStatic: TrinaryLogic::createYes()), + 'static-pure-Closure', + ]; + + yield [ + ClosureType::createPure(), + 'pure-Closure', + ]; } #[DataProvider('dataToPhpDocNode')] @@ -522,6 +537,11 @@ public static function dataToPhpDocNodeWithoutCheckingEquals(): iterable new ConstantFloatType(-0.0), '-0.0', ]; + + yield [ + new ClosureType(), + 'Closure', + ]; } #[DataProvider('dataToPhpDocNodeWithoutCheckingEquals')] From 1dd9c0ac60a973f940a8248ceb81c4f83fa19ff7 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 27 May 2026 09:32:46 +0000 Subject: [PATCH 13/13] Remove isStatic from ClosureTypeFactory runtime reflection path ReflectionFunction::isStatic() does not reliably report static closures on PHP < 8.1, so remove the isStatic parameter from the fromClosureObject() call site. This falls back to TrinaryLogic::createMaybe() (the default), which is safe for this niche runtime-reflection code path. The main analysis path through ClosureTypeResolver correctly determines isStatic from the AST. Co-Authored-By: Claude Opus 4.6 --- src/Type/ClosureTypeFactory.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Type/ClosureTypeFactory.php b/src/Type/ClosureTypeFactory.php index 8dd1b22ed12..4390073565a 100644 --- a/src/Type/ClosureTypeFactory.php +++ b/src/Type/ClosureTypeFactory.php @@ -15,7 +15,6 @@ use PHPStan\BetterReflection\SourceLocator\SourceStubber\ReflectionSourceStubber; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; -use PHPStan\TrinaryLogic; use PHPStan\Reflection\InitializerExprContext; use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\Reflection\ParameterReflection; @@ -137,7 +136,7 @@ public function getDefaultValue(): ?Type } } - return new ClosureType($parameters, TypehintHelper::decideTypeFromReflection(ReflectionType::fromTypeOrNull($betterReflectionFunction->getReturnType()), selfClass: $selfClass), $betterReflectionFunction->isVariadic(), isStatic: TrinaryLogic::createFromBoolean($closureReflectionFunction->isStatic())); + return new ClosureType($parameters, TypehintHelper::decideTypeFromReflection(ReflectionType::fromTypeOrNull($betterReflectionFunction->getReturnType()), selfClass: $selfClass), $betterReflectionFunction->isVariadic()); } }