From d81067dbdeaaafd0e268dddad3db2492268d3722 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 1 Sep 2026 14:33:42 +0200 Subject: [PATCH 01/28] Bidirectional type narrowing initial implementation Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GNLiox4nj8c7YstGfsuZ39 --- conf/bleedingEdge.neon | 1 + conf/config.neon | 1 + conf/parametersSchema.neon | 1 + src/Analyser/DirectInternalScopeFactory.php | 6 + .../ExprHandler/ArrayDimFetchHandler.php | 3 +- src/Analyser/ExprHandler/AssignHandler.php | 32 + src/Analyser/ExprHandler/FuncCallHandler.php | 5 +- .../Helper/ClosureTypeResolver.php | 8 +- .../Helper/ImplicitToStringCallHelper.php | 3 +- .../Helper/MethodCallReturnTypeHelper.php | 7 +- .../ExprHandler/MethodCallHandler.php | 3 +- src/Analyser/ExprHandler/NewHandler.php | 156 ++-- .../ExprHandler/NullsafeMethodCallHandler.php | 3 +- .../ExprHandler/StaticCallHandler.php | 3 +- src/Analyser/ExpressionResult.php | 12 + .../Generics/TemplateArgumentConstraints.php | 104 +++ .../Generics/TemplateArgumentFrame.php | 192 ++++ .../Generics/TemplateArgumentObserver.php | 260 ++++++ .../Generics/TemplateArgumentResolver.php | 81 ++ .../Generics/TemplateArgumentSolver.php | 211 +++++ .../Generics/TemplateArgumentStats.php | 108 +++ src/Analyser/InternalScopeFactory.php | 4 + src/Analyser/InternalStatementResult.php | 6 + src/Analyser/LazyInternalScopeFactory.php | 6 + src/Analyser/MutatingScope.php | 273 +++++- src/Analyser/NodeCallbackScope.php | 2 + src/Analyser/NodeScopeResolver.php | 658 +++++++++++--- src/Analyser/RecordingNodeCallback.php | 6 + src/Analyser/StatementListWalkState.php | 42 + .../StmtHandler/ExpressionHandler.php | 2 +- src/Analyser/StmtHandler/ReturnHandler.php | 4 +- .../GenericTypeTemplateTraverser.php | 41 +- src/Reflection/ResolvedFunctionVariant.php | 11 + .../ResolvedFunctionVariantWithCallable.php | 7 + .../ResolvedFunctionVariantWithOriginal.php | 65 +- src/Testing/RuleTestCase.php | 5 + src/Testing/TypeInferenceTestCase.php | 5 + src/Type/Generic/TemplateTypeHelper.php | 6 + .../UnresolvedTemplateArgumentType.php | 851 ++++++++++++++++++ src/Type/ObjectType.php | 5 + .../Analyser/AnalyserIntegrationTest.php | 5 +- tests/PHPStan/Analyser/AnalyserTest.php | 5 + .../Analyser/Generics/MinimalReWalkTest.php | 43 + .../Generics/TemplateArgumentFlowTest.php | 29 + .../Generics/TemplateArgumentResolverTest.php | 293 ++++++ .../Generics/data/constraint-flow.php | 79 ++ .../Analyser/Generics/data/minimal-rewalk.php | 48 + .../Analyser/nsrt/assert-class-type.php | 4 +- tests/PHPStan/Analyser/nsrt/bug-10254.php | 14 +- tests/PHPStan/Analyser/nsrt/bug-14203.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-15147.php | 34 + tests/PHPStan/Analyser/nsrt/bug-5508.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-6695.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-6732.php | 280 ++++++ tests/PHPStan/Analyser/nsrt/bug-6993.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-7788.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-8441.php | 4 +- tests/PHPStan/Analyser/nsrt/bug-8540.php | 36 + tests/PHPStan/Analyser/nsrt/ext-ds.php | 6 +- .../PHPStan/Analyser/nsrt/generic-static.php | 4 +- .../nsrt/generics-do-not-generalize.php | 10 +- .../Analyser/nsrt/generics-empty-array.php | 4 +- tests/PHPStan/Analyser/nsrt/generics.php | 26 +- .../nsrt/native-reflection-default-values.php | 2 +- .../nested-generic-incomplete-constructor.php | 4 +- .../Analyser/nsrt/node-callback-scope.php | 2 +- tests/PHPStan/Analyser/nsrt/self-out.php | 10 +- .../unresolved-template-argument-never.php | 80 ++ tests/PHPStan/Generics/data/classes-5.json | 10 - tests/PHPStan/Internal/LruCacheTest.php | 3 + tests/PHPStan/Levels/data/arrayAccess-10.json | 2 +- tests/PHPStan/Levels/data/arrayAccess-3.json | 24 +- tests/PHPStan/Levels/data/arrayAccess-7.json | 4 +- .../Arrays/OffsetAccessAssignmentRuleTest.php | 4 +- ...mpossibleCheckTypeFunctionCallRuleTest.php | 4 +- .../CallToFunctionParametersRuleTest.php | 10 + .../Rules/Functions/ReturnTypeRuleTest.php | 2 +- .../PHPStan/Rules/Functions/data/bug-6732.php | 30 + .../Rules/Methods/CallMethodsRuleTest.php | 61 +- .../Rules/Methods/ReturnTypeRuleTest.php | 24 +- tests/PHPStan/Rules/Methods/data/bug-5372.php | 12 +- tests/PHPStan/Rules/Methods/data/bug-6732.php | 41 + .../WrongVariableNameInVarTagRuleTest.php | 6 +- .../TypesAssignedToPropertiesRuleTest.php | 26 +- .../Rules/Properties/data/bug-3777.php | 4 +- .../Rules/Properties/data/bug-6732.php | 40 + .../UnresolvedTemplateArgumentTypeTest.php | 146 +++ 87 files changed, 4312 insertions(+), 372 deletions(-) create mode 100644 src/Analyser/Generics/TemplateArgumentConstraints.php create mode 100644 src/Analyser/Generics/TemplateArgumentFrame.php create mode 100644 src/Analyser/Generics/TemplateArgumentObserver.php create mode 100644 src/Analyser/Generics/TemplateArgumentResolver.php create mode 100644 src/Analyser/Generics/TemplateArgumentSolver.php create mode 100644 src/Analyser/Generics/TemplateArgumentStats.php create mode 100644 src/Analyser/StatementListWalkState.php create mode 100644 src/Type/Generic/UnresolvedTemplateArgumentType.php create mode 100644 tests/PHPStan/Analyser/Generics/MinimalReWalkTest.php create mode 100644 tests/PHPStan/Analyser/Generics/TemplateArgumentFlowTest.php create mode 100644 tests/PHPStan/Analyser/Generics/TemplateArgumentResolverTest.php create mode 100644 tests/PHPStan/Analyser/Generics/data/constraint-flow.php create mode 100644 tests/PHPStan/Analyser/Generics/data/minimal-rewalk.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15147.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-6732.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-8540.php create mode 100644 tests/PHPStan/Analyser/nsrt/unresolved-template-argument-never.php create mode 100644 tests/PHPStan/Rules/Functions/data/bug-6732.php create mode 100644 tests/PHPStan/Rules/Methods/data/bug-6732.php create mode 100644 tests/PHPStan/Rules/Properties/data/bug-6732.php create mode 100644 tests/PHPStan/Type/Generic/UnresolvedTemplateArgumentTypeTest.php diff --git a/conf/bleedingEdge.neon b/conf/bleedingEdge.neon index a2f57a4871f..04652767786 100644 --- a/conf/bleedingEdge.neon +++ b/conf/bleedingEdge.neon @@ -27,3 +27,4 @@ parameters: switchConditionAlwaysFalse: true checkImportedClassNameCase: true sortWithoutEffect: true + unresolvedTemplateArguments: true diff --git a/conf/config.neon b/conf/config.neon index 16e588434ac..71222e5ed2c 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -58,6 +58,7 @@ parameters: switchConditionAlwaysFalse: false checkImportedClassNameCase: false sortWithoutEffect: false + unresolvedTemplateArguments: false fileExtensions: - php checkAdvancedIsset: false diff --git a/conf/parametersSchema.neon b/conf/parametersSchema.neon index 5d968573089..7a6dc788adf 100644 --- a/conf/parametersSchema.neon +++ b/conf/parametersSchema.neon @@ -56,6 +56,7 @@ parametersSchema: switchConditionAlwaysFalse: bool() checkImportedClassNameCase: bool() sortWithoutEffect: bool() + unresolvedTemplateArguments: bool() ]) fileExtensions: listOf(string()) checkAdvancedIsset: bool() diff --git a/src/Analyser/DirectInternalScopeFactory.php b/src/Analyser/DirectInternalScopeFactory.php index 082d0460d23..66c81a44cf3 100644 --- a/src/Analyser/DirectInternalScopeFactory.php +++ b/src/Analyser/DirectInternalScopeFactory.php @@ -3,6 +3,8 @@ namespace PHPStan\Analyser; use PhpParser\Node; +use PHPStan\Analyser\Generics\TemplateArgumentConstraints; +use PHPStan\Analyser\Generics\TemplateArgumentFrame; use PHPStan\DependencyInjection\Container; use PHPStan\DependencyInjection\ExtensionsCollection; use PHPStan\Node\Printer\ExprPrinter; @@ -64,6 +66,8 @@ public function create( bool $afterExtractCall = false, ?MutatingScope $parentScope = null, bool $nativeTypesPromoted = false, + ?TemplateArgumentFrame $templateArgumentFrame = null, + ?TemplateArgumentConstraints $templateArgumentConstraints = null, ): MutatingScope { $className = MutatingScope::class; @@ -103,6 +107,8 @@ public function create( $afterExtractCall, $parentScope, $nativeTypesPromoted, + $templateArgumentFrame, + $templateArgumentConstraints, ); } diff --git a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php index bd0172f3804..00ff8d28352 100644 --- a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php +++ b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php @@ -17,6 +17,7 @@ use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\MethodCallReturnTypeHelper; use PHPStan\Analyser\ExprHandler\Helper\MethodThrowPointHelper; +use PHPStan\Analyser\Generics\TemplateArgumentFrame; use PHPStan\Analyser\IssetabilityDescriptor; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; @@ -110,7 +111,7 @@ public function composeResult(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, // flavour); the fabricated node is only the payload dynamic return // type extensions receive - nothing walks it. Gated by the same // maybe-ArrayAccess condition, so plain arrays never reach it. - $offsetGetCall = new MethodCall($expr->var, new Identifier('offsetGet'), [new Arg($expr->dim)]); + $offsetGetCall = new MethodCall($expr->var, new Identifier('offsetGet'), [new Arg($expr->dim)], [TemplateArgumentFrame::SYNTHETIC_SITE_ATTRIBUTE => true]); } return $this->expressionResultFactory->create( diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index a5df088329d..e91b5c77c25 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -35,6 +35,7 @@ use PHPStan\Analyser\ExprHandler\Helper\MethodThrowPointHelper; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; use PHPStan\Analyser\ExprHandler\Helper\VirtualExprResultHelper; +use PHPStan\Analyser\Generics\TemplateArgumentObserver; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; @@ -102,6 +103,7 @@ final class AssignHandler implements ExprHandler { public function __construct( + private TemplateArgumentObserver $templateArgumentObserver, private VarAnnotationProcessor $varAnnotationProcessor, private PhpVersion $phpVersion, private ExprPrinter $exprPrinter, @@ -238,6 +240,17 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $this->varAnnotationProcessor->processVarAnnotation($scope, $vars, $stmt, $varChangedScope); if (!$varChangedScope) { $scope = $nodeScopeResolver->processStmtVarAnnotation($scope, $storage, $stmt, null, $nodeCallback); + } else { + // the @var tag is a declared type the assigned value flows into + $templateArgumentFrame = $nodeScopeResolver->observingTemplateArgumentFrame($scope); + if ($templateArgumentFrame !== null) { + foreach ($vars as $var) { + if ($scope->hasVariableType($var)->no()) { + continue; + } + $scope = $scope->addTemplateArgumentConstraints($this->templateArgumentObserver->collectSend($scope->getVariableType($var), $assignedExprResult->getType())); + } + } } } @@ -1257,6 +1270,9 @@ public function applyWrite( $nativeScopeBeforeAssignEval = $scopeBeforeAssignEval->doNotTreatPhpDocTypesAsCertain(); $valueToWrite = $this->readAssignedValueType($nodeScopeResolver, $storedValueResult, $assignedExpr, $scopeBeforeAssignEval); $nativeValueToWrite = $this->readAssignedValueType($nodeScopeResolver, $storedValueResult, $assignedExpr, $nativeScopeBeforeAssignEval); + // the value the write puts in, before the chain walk below rebuilds + // $valueToWrite into the containers of the enclosing dimensions + $writtenValueType = $valueToWrite; [$varType, $varNativeType] = $this->resolveContainerTypesAfterAssignedExprEval($nodeScopeResolver, $var, $varResult, $scope, $scopeBeforeAssignEval, $storage); @@ -1349,6 +1365,12 @@ public function applyWrite( && !$setVarType->isArray()->yes() && !(new ObjectType(ArrayAccess::class))->isSuperTypeOf($setVarType)->no() ) { + $scope = $scope->addTemplateArgumentConstraints($nodeScopeResolver->collectOffsetSetUsage( + $scope, + $setVarType, + count($offsetTypes) > 0 ? $offsetTypes[count($offsetTypes) - 1][0] : null, + $writtenValueType, + )); $throwPoints = array_merge($throwPoints, $this->methodThrowPointHelper->getThrowPointsForCallOnType( $scope, $context, @@ -1378,6 +1400,10 @@ public function applyWrite( if ($propertyName !== null && $propertyHolderType->hasInstanceProperty($propertyName)->yes()) { $propertyReflection = $propertyHolderType->getInstanceProperty($propertyName, $scope); $assignedExprType = $this->readAssignedValueType($nodeScopeResolver, $assignedValueResult, $assignedExpr, $scope); + $templateArgumentFrame = $nodeScopeResolver->observingTemplateArgumentFrame($scope); + if ($templateArgumentFrame !== null) { + $scope = $scope->addTemplateArgumentConstraints($this->templateArgumentObserver->collectSend($propertyReflection->getWritableType(), $assignedExprType)); + } $nodeScopeResolver->callNodeCallback($nodeCallback, new PropertyAssignNode($var, $assignedExpr, $isAssignOp), $scopeBeforeAssignEval, $storage); if ($propertyReflection->canChangeTypeAfterAssignment()) { if ($propertyReflection->hasNativeType()) { @@ -1469,6 +1495,12 @@ public function applyWrite( if ($propertyName !== null) { $propertyReflection = $scope->getStaticPropertyReflection($propertyHolderType, $propertyName); $assignedExprType = $this->readAssignedValueType($nodeScopeResolver, $assignedValueResult, $assignedExpr, $scope); + if ($propertyReflection !== null) { + $templateArgumentFrame = $nodeScopeResolver->observingTemplateArgumentFrame($scope); + if ($templateArgumentFrame !== null) { + $scope = $scope->addTemplateArgumentConstraints($this->templateArgumentObserver->collectSend($propertyReflection->getWritableType(), $assignedExprType)); + } + } $nodeScopeResolver->callNodeCallback($nodeCallback, new PropertyAssignNode($var, $assignedExpr, $isAssignOp), $scopeBeforeAssignEval, $storage); if ($propertyReflection !== null && $propertyReflection->canChangeTypeAfterAssignment()) { if ($propertyReflection->hasNativeType()) { diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index b9d3aadfddb..e7d32ebd4bb 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -24,6 +24,7 @@ use PHPStan\Analyser\ExprHandler\Helper\DynamicReturnTypeStoragePrimer; use PHPStan\Analyser\ExprHandler\Helper\EarlyTerminatingCallHelper; use PHPStan\Analyser\ExprHandler\Helper\FuncCallScopeEffectsHelper; +use PHPStan\Analyser\Generics\TemplateArgumentFrame; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; @@ -660,7 +661,7 @@ private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, Mutatin } } - return $parametersAcceptor->getReturnType(); + return TemplateArgumentFrame::returnTypeOfCall($parametersAcceptor, $reflectionScope, $expr); } if (!$this->reflectionProvider->hasFunction($expr->name, $reflectionScope)) { @@ -732,7 +733,7 @@ private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, Mutatin // the typeCallback keeps void; ExpressionResult projects void->null for // value reads, getKeepVoidType() keeps it - return $parametersAcceptor->getReturnType(); + return TemplateArgumentFrame::returnTypeOfCall($parametersAcceptor, $reflectionScope, $expr); } /** diff --git a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php index 9afc63c3eb7..a847048eb42 100644 --- a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php +++ b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php @@ -413,7 +413,13 @@ private function closureContextCacheKey(MutatingScope $scope, Node\Expr\Closure| $parts[] = $parameter->getType()->describe(VerbosityLevel::cache()); } - return $scope->getClosureScopeCacheKey($this->freeVariableRoots($expr)) . '/' . implode('|', $parts) . ($scope->nativeTypesPromoted ? '/native' : '/phpdoc'); + // a closure whose body creates an unresolved template argument has the + // same key in both passes of the enclosing body; the resolutions installed + // for the second pass change its type + $frame = $scope->getCurrentTemplateArgumentFrame(); + + return $scope->getClosureScopeCacheKey($this->freeVariableRoots($expr)) . '/' . implode('|', $parts) . ($scope->nativeTypesPromoted ? '/native' : '/phpdoc') + . ($frame !== null ? $frame->getResolutionCacheKeySuffix() : ''); } /** diff --git a/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php index 671824c3ed6..815e8bbbd5d 100644 --- a/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php +++ b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php @@ -7,6 +7,7 @@ use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; +use PHPStan\Analyser\Generics\TemplateArgumentFrame; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\SpecifiedTypes; @@ -74,7 +75,7 @@ public function processImplicitToStringCall(Expr $expr, MutatingScope $scope, Ex // the __toString() call's return type resolves directly (the receiver // type is already in hand); the fabricated node is only the payload // dynamic extensions receive - nothing walks it - $toStringCall = new Expr\MethodCall($expr, new Identifier('__toString')); + $toStringCall = new Expr\MethodCall($expr, new Identifier('__toString'), attributes: [TemplateArgumentFrame::SYNTHETIC_SITE_ATTRIBUTE => true]); if ($scope->nativeTypesPromoted) { $toStringReturnType = ParametersAcceptorSelector::combineAcceptors($toStringMethod->getVariants())->getNativeReturnType(); } else { diff --git a/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php b/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php index 2bc9cf5a786..e2acd76dabe 100644 --- a/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php +++ b/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php @@ -6,6 +6,7 @@ use PhpParser\Node\Expr\MethodCall; use PHPStan\Analyser\ArgsResult; use PHPStan\Analyser\ArgumentsNormalizer; +use PHPStan\Analyser\Generics\TemplateArgumentFrame; use PHPStan\Analyser\MutatingScope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\ParametersAcceptor; @@ -54,7 +55,7 @@ public function methodCallReturnType( $normalizedMethodCall = ArgumentsNormalizer::reorderStaticCallArguments($parametersAcceptor, $methodCall); } if ($normalizedMethodCall === null) { - return $parametersAcceptor->getReturnType(); + return TemplateArgumentFrame::returnTypeOfCall($parametersAcceptor, $scope, $methodCall); } // re-expose the already-processed arguments so an extension's @@ -116,7 +117,7 @@ public function methodCallReturnType( $remainingMethod->getVariants(), $remainingMethod->getNamedArgumentsVariants(), ); - $resolvedTypes[] = $remainingParametersAcceptor->getReturnType(); + $resolvedTypes[] = TemplateArgumentFrame::returnTypeOfCall($remainingParametersAcceptor, $scope, $methodCall); } } @@ -126,7 +127,7 @@ public function methodCallReturnType( $popPrimedStorage(); } - return $parametersAcceptor->getReturnType(); + return TemplateArgumentFrame::returnTypeOfCall($parametersAcceptor, $scope, $methodCall); } } diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 2fe2476053b..bf91b2dc5be 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -19,6 +19,7 @@ use PHPStan\Analyser\ExprHandler\Helper\EarlyTerminatingCallHelper; use PHPStan\Analyser\ExprHandler\Helper\MethodCallReturnTypeHelper; use PHPStan\Analyser\ExprHandler\Helper\MethodThrowPointHelper; +use PHPStan\Analyser\Generics\TemplateArgumentFrame; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; @@ -305,7 +306,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // processArgs() selected (generics resolved against the actual arg // types), falling back to the structural acceptor for dynamic callees. $acceptorForGenerics = $resolvedParametersAcceptor ?? $parametersAcceptor; - $rememberedType = $acceptorForGenerics->getReturnType(); + $rememberedType = TemplateArgumentFrame::returnTypeOfCall($acceptorForGenerics, $scope, $expr); if ($varResult->containsNullsafe() && TypeCombinator::containsNull($calledOnType)) { // a call on a nullsafe chain whose receiver is nullable // short-circuits to null - the tracked entry is keyed by the diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 299e9dbfd36..d33508b7115 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -17,6 +17,7 @@ use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\DynamicReturnTypeStoragePrimer; +use PHPStan\Analyser\Generics\TemplateArgumentFrame; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; @@ -56,6 +57,7 @@ use PHPStan\Type\Generic\TemplateTypeMap; use PHPStan\Type\Generic\TemplateTypeVariance; use PHPStan\Type\Generic\TemplateTypeVarianceMap; +use PHPStan\Type\Generic\UnresolvedTemplateArgumentType; use PHPStan\Type\NeverType; use PHPStan\Type\NonexistentParentClassType; use PHPStan\Type\ObjectType; @@ -202,7 +204,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // the not-yet-stored New_ node, which would re-enter this handler. $objectClasses = $classResult->getType()->getObjectTypeOrClassStringObjectType()->getObjectClassNames(); if (count($objectClasses) === 1) { - $objectExprResult = $nodeScopeResolver->processExprNode($stmt, new New_(new Name($objectClasses[0])), $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); + $objectExprResult = $nodeScopeResolver->processExprNode($stmt, new New_(new Name($objectClasses[0]), attributes: [TemplateArgumentFrame::SYNTHETIC_SITE_ATTRIBUTE => true]), $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); $className = $objectClasses[0]; $additionalThrowPoints = $objectExprResult->getThrowPoints(); } else { @@ -260,6 +262,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nativeTypesPromoted ? null : $resolvedParametersAcceptor, $classResult !== null ? ($nativeTypesPromoted ? $classResult->getNativeType() : $classResult->getType()) : null, $argsResult, + !$nativeTypesPromoted, ); $specifyTypesCallback = fn (TypeSpecifierContext $specifyContext, bool $nativeTypesPromoted): SpecifiedTypes => $this->specifyTypes( $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, @@ -438,10 +441,10 @@ private function getConstructorThrowPoint(MethodReflection $constructorReflectio * * @param New_ $expr */ - private function resolveReturnType(MutatingScope $scope, Expr $expr, ?ParametersAcceptor $preResolvedAcceptor, ?Type $classExprType, ?ArgsResult $argsResult = null): Type + private function resolveReturnType(MutatingScope $scope, Expr $expr, ?ParametersAcceptor $preResolvedAcceptor, ?Type $classExprType, ?ArgsResult $argsResult = null, bool $allowUnresolved = true): Type { if ($expr->class instanceof Name) { - return $this->exactInstantiation($scope, $expr, $expr->class, $preResolvedAcceptor, $argsResult); + return $this->exactInstantiation($scope, $expr, $expr->class, $preResolvedAcceptor, $argsResult, $allowUnresolved); } if ($expr->class instanceof Node\Stmt\Class_) { $anonymousClassReflection = $this->reflectionProvider->getAnonymousClassReflection($expr->class, $scope); @@ -457,7 +460,10 @@ private function resolveReturnType(MutatingScope $scope, Expr $expr, ?Parameters return $classExprType->getObjectTypeOrClassStringObjectType(); } - private function exactInstantiation(MutatingScope $scope, New_ $node, Name $className, ?ParametersAcceptor $preResolvedAcceptor, ?ArgsResult $argsResult = null): Type + /** + * @param bool $allowUnresolved false for the native flavour, which never carries UnresolvedTemplateArgumentType + */ + private function exactInstantiation(MutatingScope $scope, New_ $node, Name $className, ?ParametersAcceptor $preResolvedAcceptor, ?ArgsResult $argsResult = null, bool $allowUnresolved = true): Type { $resolvedClassName = $scope->resolveName($className); $isStatic = false; @@ -563,6 +569,17 @@ private function exactInstantiation(MutatingScope $scope, New_ $node, Name $clas return $objectType; } + $frame = $scope->getCurrentTemplateArgumentFrame(); + // the class's arguments when the constructor says nothing about them + $unresolvedArguments = function () use ($classReflection, $node, $frame, $allowUnresolved, $isStatic, $resolvedClassName): Type { + $types = $this->unresolvedArgumentList($classReflection, $node, $frame, $allowUnresolved); + if ($isStatic) { + return new GenericStaticType($classReflection, $types, null, []); + } + + return new GenericObjectType($resolvedClassName, $types, classReflection: $classReflection->withTypes($types)->asFinal()); + }; + $assignedToProperty = $node->getAttribute(NewAssignedToPropertyVisitor::ATTRIBUTE_NAME); if ($assignedToProperty !== null) { $constructorVariants = $constructorMethod->getVariants(); @@ -597,80 +614,24 @@ private function exactInstantiation(MutatingScope $scope, New_ $node, Name $clas } if ($constructorMethod instanceof DummyConstructorReflection) { - if ($isStatic) { - return new GenericStaticType( - $classReflection, - $classReflection->typeMapToList($classReflection->getTemplateTypeMap()->resolveToBounds()), - null, - [], - ); - } - - $types = $classReflection->typeMapToList($classReflection->getTemplateTypeMap()->resolveToBounds()); - return new GenericObjectType( - $resolvedClassName, - $types, - classReflection: $classReflection->withTypes($types)->asFinal(), - ); + return $unresolvedArguments(); } if ($constructorMethod->getDeclaringClass()->getName() !== $classReflection->getName()) { if (!$constructorMethod->getDeclaringClass()->isGeneric()) { - if ($isStatic) { - return new GenericStaticType( - $classReflection, - $classReflection->typeMapToList($classReflection->getTemplateTypeMap()->resolveToBounds()), - null, - [], - ); - } - - $types = $classReflection->typeMapToList($classReflection->getTemplateTypeMap()->resolveToBounds()); - return new GenericObjectType( - $resolvedClassName, - $types, - classReflection: $classReflection->withTypes($types)->asFinal(), - ); + return $unresolvedArguments(); } $newType = new GenericObjectType($resolvedClassName, $classReflection->typeMapToList($classReflection->getTemplateTypeMap())); $ancestorType = $newType->getAncestorWithClassName($constructorMethod->getDeclaringClass()->getName()); if ($ancestorType === null) { - if ($isStatic) { - return new GenericStaticType( - $classReflection, - $classReflection->typeMapToList($classReflection->getTemplateTypeMap()->resolveToBounds()), - null, - [], - ); - } - - $types = $classReflection->typeMapToList($classReflection->getTemplateTypeMap()->resolveToBounds()); - return new GenericObjectType( - $resolvedClassName, - $types, - classReflection: $classReflection->withTypes($types)->asFinal(), - ); + return $unresolvedArguments(); } $ancestorClassReflections = $ancestorType->getObjectClassReflections(); if (count($ancestorClassReflections) !== 1) { - if ($isStatic) { - return new GenericStaticType( - $classReflection, - $classReflection->typeMapToList($classReflection->getTemplateTypeMap()->resolveToBounds()), - null, - [], - ); - } - - $types = $classReflection->typeMapToList($classReflection->getTemplateTypeMap()->resolveToBounds()); - return new GenericObjectType( - $resolvedClassName, - $types, - classReflection: $classReflection->withTypes($types)->asFinal(), - ); + return $unresolvedArguments(); } - $newParentNode = new New_(new Name($constructorMethod->getDeclaringClass()->getName()), $node->args); + $newParentNode = new New_(new Name($constructorMethod->getDeclaringClass()->getName()), $node->args, [TemplateArgumentFrame::SYNTHETIC_SITE_ATTRIBUTE => true]); // the synthetic walk is load-bearing: it re-resolves the parent // constructor's template types from the arguments (processArgs against // the parent's signature), which a direct exactInstantiation() recursion @@ -678,21 +639,7 @@ classReflection: $classReflection->withTypes($types)->asFinal(), $newParentType = $this->container->getByType(NodeScopeResolver::class)->processSyntheticOnDemand($newParentNode, $scope)->getTypeOnScope($scope, false); $newParentTypeClassReflections = $newParentType->getObjectClassReflections(); if (count($newParentTypeClassReflections) !== 1) { - if ($isStatic) { - return new GenericStaticType( - $classReflection, - $classReflection->typeMapToList($classReflection->getTemplateTypeMap()->resolveToBounds()), - null, - [], - ); - } - - $types = $classReflection->typeMapToList($classReflection->getTemplateTypeMap()->resolveToBounds()); - return new GenericObjectType( - $resolvedClassName, - $types, - classReflection: $classReflection->withTypes($types)->asFinal(), - ); + return $unresolvedArguments(); } $newParentTypeClassReflection = $newParentTypeClassReflections[0]; @@ -713,6 +660,11 @@ classReflection: $classReflection->withTypes($types)->asFinal(), } $ancestorType = $ancestorMapping[$typeName]; + if ($type instanceof UnresolvedTemplateArgumentType) { + // inferred by the parent constructor under the synthetic node: + // this node is the site, the child's template the argument + $type = $this->rekeyParentTemplateArgument($type, $node, $ancestorType, $frame, $allowUnresolved); + } if (!$ancestorType->getBound()->isSuperTypeOf($type)->yes()) { continue; } @@ -762,7 +714,49 @@ classReflection: $classReflection->withTypes($types)->asFinal(), return $newGenericType; } - return TypeTraverser::map($newGenericType, new GenericTypeTemplateTraverser($resolvedTemplateTypeMap)); + return TypeTraverser::map($newGenericType, new GenericTypeTemplateTraverser($resolvedTemplateTypeMap, $node, $frame, $allowUnresolved)); + } + + /** + * The class's template arguments when the constructor says nothing about + * them: unresolved markers during a body's observation pass, the frame's + * resolutions (never, when nothing constrained them) during its second + * pass, the bounds outside any frame. + * + * @return list + */ + private function unresolvedArgumentList(ClassReflection $classReflection, New_ $site, ?TemplateArgumentFrame $frame, bool $allowUnresolved): array + { + if ($frame === null) { + return $classReflection->typeMapToList($classReflection->getTemplateTypeMap()->resolveToBounds()); + } + + // a synthetic site (the parent constructor's `new`) always hands out + // markers - the real site re-keys and resolves them + $synthetic = $site->getAttribute(TemplateArgumentFrame::SYNTHETIC_SITE_ATTRIBUTE) === true; + + return $classReflection->typeMapToList($classReflection->getTemplateTypeMap()->map(static function (string $name, Type $type) use ($site, $frame, $allowUnresolved, $synthetic): Type { + if (!$type instanceof TemplateType) { + return $type; + } + if ($synthetic || ($allowUnresolved && $frame->isObserving())) { + return new UnresolvedTemplateArgumentType($site, $type, null); + } + + return $frame->resolveOrUnconstrained($site, $type); + })); + } + + private function rekeyParentTemplateArgument(UnresolvedTemplateArgumentType $type, New_ $site, TemplateType $template, ?TemplateArgumentFrame $frame, bool $allowUnresolved): Type + { + if ($frame === null) { + return $type->getInitialType() ?? $template->getDefault() ?? $template->getBound(); + } + if ($allowUnresolved && $frame->isObserving()) { + return $type->withSite($site, $template); + } + + return $frame->resolve($site, $template->getName()) ?? $type->getInitialType() ?? $frame->resolveOrUnconstrained($site, $template); } /** diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index 97320b29775..e433621aec5 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -17,6 +17,7 @@ use PHPStan\Analyser\ExprHandler\Helper\BooleanNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; +use PHPStan\Analyser\Generics\TemplateArgumentFrame; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\SpecifiedTypes; @@ -76,7 +77,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // walk itself will consume - exactly what storing the receiver walked // inside the twin used to produce $nodeScopeResolver->storeExpressionResult($storage, $expr->var, $processedReceiverResult->atAskPosition($nonNullabilityResult->getScope())); - $attributes = array_merge($expr->getAttributes(), ['virtualNullsafeMethodCall' => true]); + $attributes = array_merge($expr->getAttributes(), ['virtualNullsafeMethodCall' => true, TemplateArgumentFrame::ORIGINAL_SITE_ATTRIBUTE => $expr]); unset($attributes[ExprPrinter::ATTRIBUTE_CACHE_KEY]); $methodCall = new MethodCall( $expr->var, diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index 2b5ee8e8a4a..5f04c592b95 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -20,6 +20,7 @@ use PHPStan\Analyser\ExprHandler\Helper\EarlyTerminatingCallHelper; use PHPStan\Analyser\ExprHandler\Helper\MethodCallReturnTypeHelper; use PHPStan\Analyser\ExprHandler\Helper\MethodThrowPointHelper; +use PHPStan\Analyser\Generics\TemplateArgumentFrame; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; @@ -392,7 +393,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $acceptorForGenerics = $resolvedParametersAcceptor ?? $parametersAcceptor; $scope = $scope->assignExpression( new PossiblyImpureCallExpr($normalizedExpr, new Variable('this'), sprintf('%s::%s()', $methodReflection->getDeclaringClass()->getDisplayName(), $methodReflection->getName())), - $acceptorForGenerics->getReturnType(), + TemplateArgumentFrame::returnTypeOfCall($acceptorForGenerics, $scope, $expr), new MixedType(), ); } diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index c6aeb3b157f..bcee1c9eee5 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -141,6 +141,18 @@ public function getScope(): MutatingScope return $this->scope; } + public function withScope(MutatingScope $scope): self + { + if ($scope === $this->scope) { + return $this; + } + $result = clone $this; + $result->scope = $scope; + $result->truthyScope = null; + $result->falseyScope = null; + return $result; + } + public function getBeforeScope(): MutatingScope { return $this->beforeScope; diff --git a/src/Analyser/Generics/TemplateArgumentConstraints.php b/src/Analyser/Generics/TemplateArgumentConstraints.php new file mode 100644 index 00000000000..0710fcf1205 --- /dev/null +++ b/src/Analyser/Generics/TemplateArgumentConstraints.php @@ -0,0 +1,104 @@ +left === null && $this->right === null && $this->fact === null; + } + + public function merge(self $other): self + { + if ($this === $other || $other->isEmpty()) { + return $this; + } + if ($this->isEmpty()) { + return $other; + } + + return new self($this, $other); + } + + public function withSite(UnresolvedTemplateArgumentType $marker): self + { + if ($marker->getSite()->getAttribute(TemplateArgumentFrame::SYNTHETIC_SITE_ATTRIBUTE) === true) { + return $this; + } + + return new self($this, fact: [$marker, null, null, false]); + } + + public function withSend(UnresolvedTemplateArgumentType $marker, Type $type, TemplateTypeVariance $variance): self + { + return new self($this, fact: [$marker, $type, $variance, false]); + } + + public function withLowerBound(UnresolvedTemplateArgumentType $marker, Type $type): self + { + return new self($this, fact: [$marker, $type, null, false]); + } + + public function withUnconstrainingSend(UnresolvedTemplateArgumentType $marker): self + { + return new self($this, fact: [$marker, null, null, true]); + } + + /** @return iterable */ + public function getFacts(): iterable + { + $stack = [[$this, false]]; + $visited = []; + while ($stack !== []) { + [$current, $expanded] = array_pop($stack); + if ($expanded) { + if ($current->fact !== null) { + yield $current->fact; + } + continue; + } + $id = spl_object_id($current); + if (isset($visited[$id])) { + continue; + } + $visited[$id] = true; + $stack[] = [$current, true]; + if ($current->right !== null) { + $stack[] = [$current->right, false]; + } + if ($current->left === null) { + continue; + } + + $stack[] = [$current->left, false]; + } + } + +} diff --git a/src/Analyser/Generics/TemplateArgumentFrame.php b/src/Analyser/Generics/TemplateArgumentFrame.php new file mode 100644 index 00000000000..00f3d503e93 --- /dev/null +++ b/src/Analyser/Generics/TemplateArgumentFrame.php @@ -0,0 +1,192 @@ +getCurrentTemplateArgumentFrame(); + if ($frame === null || !$acceptor instanceof ResolvedFunctionVariant) { + return $acceptor->getReturnType(); + } + $originalSite = $site->getAttribute(self::ORIGINAL_SITE_ATTRIBUTE); + + return $acceptor->getReturnTypeWithUnresolvedTemplateArguments( + $originalSite instanceof Expr ? $originalSite : $site, + $frame, + $allowUnresolved ?? !$scope->nativeTypesPromoted, + ); + } + + /** + * @param array|null $resolutions null during collection + * @param array $siteStatementIndexes + */ + public function __construct( + private readonly ?self $parent, + private readonly ?array $resolutions = null, + private readonly array $siteStatementIndexes = [], + ) + { + } + + public function isObserving(): bool + { + return $this->resolutions === null; + } + + public function firstSiteStatementIndex(): ?int + { + $first = null; + foreach (array_keys($this->siteStatementIndexes) as $index) { + if ($first !== null && $index >= $first) { + continue; + } + + $first = $index; + } + + return $first; + } + + public function ownsSiteInStatement(int $statementIndex): bool + { + return isset($this->siteStatementIndexes[$statementIndex]); + } + + public function hasSiteAtOrAfter(int $statementIndex): bool + { + foreach (array_keys($this->siteStatementIndexes) as $index) { + if ($index >= $statementIndex) { + return true; + } + } + + return false; + } + + /** + * The resolution of the site's template argument, or - for a site this + * frame never observed (never asked during the observation pass, a native + * flavour) - what an unconstrained argument resolves to. + */ + public function resolveOrUnconstrained(Expr $site, TemplateType $template): Type + { + return $this->resolve($site, $template->getName()) ?? self::resolveUnconstrained($site, $template, $this->resolve(...)); + } + + /** + * Nothing was inferred, sent or passed in: the template's default, else + * its bound when it says something (`T of Foo`, `U of T` - resolved + * against the sibling arguments), else never - the object holds nothing. + * + * @param callable(Expr, string): ?Type $resolve + */ + public static function resolveUnconstrained(Expr $site, TemplateType $template, callable $resolve): Type + { + $default = $template->getDefault(); + if ($default !== null) { + return $default; + } + + $bound = $template->getBound(); + if ($bound instanceof MixedType && !$bound instanceof TemplateType) { + return new NeverType(); + } + if (!$bound->hasTemplateOrLateResolvableType()) { + return $bound; + } + + $scope = $template->getScope(); + + return TypeTraverser::map($bound, static function (Type $type, callable $traverse) use ($site, $scope, $resolve): Type { + if ($type instanceof TemplateType && $type->getScope()->equals($scope)) { + return $resolve($site, $type->getName()) ?? $type->getDefault() ?? $traverse($type->getBound()); + } + + return $traverse($type); + }); + } + + /** + * The resolved type of a template argument of the site, or null for a site + * this frame and its parents never observed. + */ + public function resolve(Expr $site, string $templateName): ?Type + { + $key = self::key($site, $templateName); + if (isset($this->resolutions[$key])) { + return $this->resolutions[$key]; + } + + if ($this->parent !== null) { + return $this->parent->resolve($site, $templateName); + } + + return null; + } + + /** + * Distinguishes cache entries computed while observing from those computed + * with the resolutions installed (the closure type cache keys on scope state + * that does not change between the two passes). + */ + public function getResolutionCacheKeySuffix(): string + { + $frame = $this; + while ($frame !== null) { + if (!$frame->isObserving()) { + return sprintf('|templateArguments:%d', spl_object_id($frame)); + } + + $frame = $frame->parent; + } + + return ''; + } + + private static function key(Expr $site, string $templateName): string + { + return spl_object_id($site) . '#' . $templateName; + } + +} diff --git a/src/Analyser/Generics/TemplateArgumentObserver.php b/src/Analyser/Generics/TemplateArgumentObserver.php new file mode 100644 index 00000000000..6024b60eb15 --- /dev/null +++ b/src/Analyser/Generics/TemplateArgumentObserver.php @@ -0,0 +1,260 @@ +withSite($type); + $initial = $type->getInitialType(); + if ($initial !== null) { + $traverse($initial); + } + return $type; + } + return $traverse($type); + }); + return $constraints; + } + + /** Skip ordinary recursive generic relationships that cannot contribute a constraint. */ + private function containsMarker(Type $type): bool + { + $contains = false; + TypeTraverser::map($type, static function (Type $type, callable $traverse) use (&$contains): Type { + if ($type instanceof UnresolvedTemplateArgumentType) { + $contains = true; + } + return $contains ? $type : $traverse($type); + }); + return $contains; + } + + public function collectSend(Type $declared, Type $actual): TemplateArgumentConstraints + { + return $this->observeSend(TemplateArgumentConstraints::createEmpty(), $declared, $actual); + } + + public function collectArgument(Type $parameterType, Type $argumentType): TemplateArgumentConstraints + { + return $this->observeArgument(TemplateArgumentConstraints::createEmpty(), $parameterType, $argumentType); + } + + /** + * $actual flows into $declared: a property's writable type, a parameter + * type, a declared return type, a @var type. + */ + private function observeSend(TemplateArgumentConstraints $constraints, Type $declared, Type $actual): TemplateArgumentConstraints + { + if ($declared instanceof TemplateType || !$this->containsMarker($actual)) { + return $constraints; + } + if ($actual instanceof UnionType) { + foreach ($actual->getTypes() as $member) { + $constraints = $this->observeSend($constraints, $declared, $member); + } + + return $constraints; + } + if ($declared instanceof UnionType) { + foreach ($declared->getTypes() as $member) { + $constraints = $this->observeSend($constraints, $member, $actual); + } + + return $constraints; + } + if ($actual instanceof UnresolvedTemplateArgumentType) { + // a bare marker is a derived value (Foo::get()) and never constrains + return $constraints; + } + if ($actual instanceof NeverType) { + // never holds no markers, and is its own iterable key and value type + return $constraints; + } + + $actualReflections = $actual->getObjectClassReflections(); + if (count($actualReflections) === 1) { + $declaredReflections = $declared->getObjectClassReflections(); + if (count($declaredReflections) !== 1) { + return $constraints; + } + $declaredReflection = $declaredReflections[0]; + + // the declared type names an ancestor: its arguments map onto the + // object's through @extends/@implements + $ancestor = $actualReflections[0]->getAncestorWithClassName($declaredReflection->getName()); + if ($ancestor === null || !$ancestor->isGeneric()) { + return $constraints; + } + + $templates = $ancestor->typeMapToList($ancestor->getTemplateTypeMap()); + $declaredArguments = $declaredReflection->typeMapToList($declaredReflection->getActiveTemplateTypeMap()); + $declaredVariances = $declaredReflection->getCallSiteVarianceMap(); + foreach ($ancestor->typeMapToList($ancestor->getActiveTemplateTypeMap()) as $i => $argument) { + $template = $templates[$i] ?? null; + if (!$template instanceof TemplateType || !isset($declaredArguments[$i])) { + continue; + } + $declaredArgument = $declaredArguments[$i]; + if (!$argument instanceof UnresolvedTemplateArgumentType) { + $constraints = $this->observeSend($constraints, $declaredArgument, $argument); + continue; + } + if (self::isUninformativeSendTarget($declaredArgument)) { + if ($declaredArgument instanceof MixedType && !$declaredArgument instanceof TemplateType) { + // mixed accepts every argument, so it decides nothing - but the + // object did leave the body through it, which is more than the + // untouched `new Foo()` that resolves to never. A target that + // still carries template types is not such a signal: it is not + // a target yet. + $constraints = $constraints->withUnconstrainingSend($argument); + } + + continue; + } + + $callSiteVariance = $declaredVariances->getVariance($template->getName()) ?? TemplateTypeVariance::createInvariant(); + $effectiveVariance = $callSiteVariance->invariant() ? $template->getVariance() : $callSiteVariance; + $constraints = $constraints->withSend($argument, $declaredArgument, $effectiveVariance); + + // a site whose inferred argument itself carries markers (wrap(new Foo(1))) + $initial = $argument->getInitialType(); + if ($initial === null) { + continue; + } + $constraints = $this->observeSend($constraints, $declaredArgument, $initial); + } + + return $constraints; + } + + if (count($actualReflections) > 0 || $actual->isObject()->yes()) { + return $constraints; + } + + if (!$actual->isIterable()->yes() || !$declared->isIterable()->yes()) { + return $constraints; + } + + $constraints = $this->observeSend($constraints, $declared->getIterableKeyType(), $actual->getIterableKeyType()); + $constraints = $this->observeSend($constraints, $declared->getIterableValueType(), $actual->getIterableValueType()); + + return $constraints; + } + + /** + * An argument was passed to a parameter: the argument's markers are sent to + * the parameter type, and a parameter type carrying the receiver's markers + * (add(T $x) on Foo) puts the argument as a lower bound on them. + */ + private function observeArgument(TemplateArgumentConstraints $constraints, Type $parameterType, Type $argumentType): TemplateArgumentConstraints + { + $constraints = $this->observeSend($constraints, $parameterType, $argumentType); + $constraints = $this->observeLowerBound($constraints, $parameterType, $argumentType); + + return $constraints; + } + + private function observeLowerBound(TemplateArgumentConstraints $constraints, Type $parameterType, Type $argumentType): TemplateArgumentConstraints + { + if (!$this->containsMarker($parameterType)) { + return $constraints; + } + if ($parameterType instanceof UnresolvedTemplateArgumentType) { + $constraints = $constraints->withLowerBound($parameterType, $argumentType); + return $constraints; + } + if ($parameterType instanceof NeverType) { + // never is its own iterable key and value type + return $constraints; + } + if ($parameterType instanceof TemplateType || $parameterType->isCallable()->yes()) { + // callable parameters put the template in a contravariant position: + // what they say about it is an upper bound, not something flowing in + return $constraints; + } + if ($parameterType instanceof UnionType) { + foreach ($parameterType->getTypes() as $member) { + $constraints = $this->observeLowerBound($constraints, $member, $argumentType); + } + + return $constraints; + } + if ($argumentType instanceof UnionType) { + foreach ($argumentType->getTypes() as $member) { + $constraints = $this->observeLowerBound($constraints, $parameterType, $member); + } + + return $constraints; + } + + $parameterReflections = $parameterType->getObjectClassReflections(); + if (count($parameterReflections) === 1) { + $parameterReflection = $parameterReflections[0]; + if (!$parameterReflection->isGeneric()) { + return $constraints; + } + $argumentReflections = $argumentType->getObjectClassReflections(); + if (count($argumentReflections) !== 1) { + return $constraints; + } + $ancestor = $argumentReflections[0]->getAncestorWithClassName($parameterReflection->getName()); + if ($ancestor === null) { + return $constraints; + } + $ancestorArguments = $ancestor->typeMapToList($ancestor->getActiveTemplateTypeMap()); + foreach ($parameterReflection->typeMapToList($parameterReflection->getActiveTemplateTypeMap()) as $i => $parameterArgument) { + if (!isset($ancestorArguments[$i])) { + continue; + } + $constraints = $this->observeLowerBound($constraints, $parameterArgument, $ancestorArguments[$i]); + } + + return $constraints; + } + + if (count($parameterReflections) > 0 || $parameterType->isObject()->yes()) { + return $constraints; + } + + if (!$parameterType->isIterable()->yes() || !$argumentType->isIterable()->yes()) { + return $constraints; + } + + $constraints = $this->observeLowerBound($constraints, $parameterType->getIterableKeyType(), $argumentType->getIterableKeyType()); + $constraints = $this->observeLowerBound($constraints, $parameterType->getIterableValueType(), $argumentType->getIterableValueType()); + + return $constraints; + } + + private static function isUninformativeSendTarget(Type $declaredArgument): bool + { + // Foo accepts every Foo (TemplateTypeVariance::isValidVariance) + // and a declared argument with unresolved template types is no target yet + return ($declaredArgument instanceof MixedType && !$declaredArgument instanceof TemplateType) + || $declaredArgument->hasTemplateOrLateResolvableType(); + } + +} diff --git a/src/Analyser/Generics/TemplateArgumentResolver.php b/src/Analyser/Generics/TemplateArgumentResolver.php new file mode 100644 index 00000000000..1328bc71383 --- /dev/null +++ b/src/Analyser/Generics/TemplateArgumentResolver.php @@ -0,0 +1,81 @@ + $statementStartTokenPositions */ + public function resolve(TemplateArgumentConstraints $constraints, ?TemplateArgumentFrame $parent, array $statementStartTokenPositions): TemplateArgumentFrame + { + $observations = []; + $sites = []; + $siteStatementIndexes = []; + foreach ($constraints->getFacts() as [$marker, $type, $variance, $unconstraining]) { + $key = spl_object_id($marker->getSite()) . '#' . $marker->getTemplateName(); + if ($unconstraining && !isset($observations[$key])) { + continue; + } + $observation = $observations[$key] ?? [ + 'marker' => $marker, + 'initial' => null, + 'sends' => [], + 'lowerBounds' => [], + 'unconstrainingSend' => false, + ]; + $initial = $marker->getInitialType(); + if ($initial !== null) { + $observation['initial'] = $observation['initial'] === null ? $initial : TypeCombinator::union($observation['initial'], $initial); + } + if ($unconstraining) { + $observation['unconstrainingSend'] = true; + } elseif ($type !== null && $variance !== null) { + $observation['sends'][] = [$type, $variance]; + } elseif ($type !== null) { + $observation['lowerBounds'][] = $type; + } + $observations[$key] = $observation; + if ($type !== null || $unconstraining) { + continue; + } + $site = $marker->getSite(); + $id = spl_object_id($site); + if (isset($sites[$id])) { + continue; + } + $sites[$id] = $site; + $siteStatementIndexes[$this->locateStatement($site->getStartTokenPos(), $statementStartTokenPositions)] = true; + if (!TemplateArgumentStats::$enabled) { + continue; + } + + TemplateArgumentStats::increment('sitesCreated'); + } + + return new TemplateArgumentFrame($parent, (new TemplateArgumentSolver($observations, $parent))->solve(), $siteStatementIndexes); + } + + /** @param list $positions */ + private function locateStatement(int $tokenPosition, array $positions): int + { + $low = 0; + $high = count($positions) - 1; + while ($low < $high) { + $mid = ($low + $high + 1) >> 1; + if ($positions[$mid] <= $tokenPosition) { + $low = $mid; + } else { + $high = $mid - 1; + } + } + + return $low; + } + +} diff --git a/src/Analyser/Generics/TemplateArgumentSolver.php b/src/Analyser/Generics/TemplateArgumentSolver.php new file mode 100644 index 00000000000..752ba95e6c5 --- /dev/null +++ b/src/Analyser/Generics/TemplateArgumentSolver.php @@ -0,0 +1,211 @@ + */ + private array $resolutions = []; + + /** @param array, lowerBounds: list, unconstrainingSend: bool}> $observations */ + public function __construct( + private array $observations, + private ?TemplateArgumentFrame $parent, + ) + { + } + + /** @return array */ + public function solve(): array + { + foreach (array_keys($this->observations) as $key) { + $this->resolveKey($key); + } + + return $this->resolutions; + } + + /** @var array */ + private array $resolving = []; + + private function resolveKey(string $key): Type + { + if (array_key_exists($key, $this->resolutions)) { + return $this->resolutions[$key]; + } + $observation = $this->observations[$key]; + if (isset($this->resolving[$key])) { + // a site whose inferred argument refers back to itself through another + // site (wrap($x = new Foo($x))): the inferred type stands + return $observation['marker']->getDelegate(); + } + + $this->resolving[$key] = true; + try { + return $this->resolutions[$key] = $this->resolveObservation($observation); + } finally { + unset($this->resolving[$key]); + } + } + + /** + * Replaces the markers of observed sites inside a type by their + * resolutions - a resolution never contains a marker, and a send must be + * checked against what the inferred argument resolves to, not against the + * opaque marker (wrap(new Foo(1)) sent to Bar> resolves the outer + * site to Foo only once the inner one is int). + */ + private function substituteResolutions(Type $type): Type + { + if ($type instanceof UnresolvedTemplateArgumentType) { + return $this->substituteMarker($type); + } + + return TypeTraverser::map($type, function (Type $type, callable $traverse): Type { + if ($type instanceof UnresolvedTemplateArgumentType) { + return $this->substituteMarker($type); + } + + return $traverse($type); + }); + } + + private function substituteMarker(UnresolvedTemplateArgumentType $marker): Type + { + $key = self::key($marker->getSite(), $marker->getTemplateName()); + if (array_key_exists($key, $this->observations)) { + return $this->resolveKey($key); + } + + $resolved = $this->parent !== null ? $this->parent->resolve($marker->getSite(), $marker->getTemplateName()) : null; + + return $resolved ?? $this->substituteResolutions($marker->getDelegate()); + } + + /** + * @param array{ + * marker: UnresolvedTemplateArgumentType, + * initial: Type|null, + * sends: list, + * lowerBounds: list, + * unconstrainingSend: bool, + * } $observation + */ + private function resolveObservation(array $observation): Type + { + $initial = $observation['initial'] !== null ? $this->substituteResolutions($observation['initial']) : null; + $lowerBounds = []; + foreach ($observation['lowerBounds'] as $lowerBound) { + $lowerBounds[] = $this->substituteResolutions($lowerBound); + } + $templateVariance = $observation['marker']->getTemplate()->getVariance(); + + // nothing inferred, or never (an empty array): every send accepts it + $acceptsAnything = $initial === null || $initial instanceof NeverType; + // a covariant template already accepts every subtype - a known initial + // type is never clamped + if (!$templateVariance->covariant() || $acceptsAnything) { + $covariantFallback = null; + foreach ($observation['sends'] as [$sent, $variance]) { + if ($variance->contravariant()) { + // Foo accepts Foo for every X wider than int + $lowerBounds[] = $sent; + continue; + } + if ($variance->covariant()) { + // an upper bound; with nothing inferred it is the best information there is + if ($acceptsAnything) { + $covariantFallback ??= $sent; + } + continue; + } + if (!$variance->invariant()) { + continue; + } + // invariant: the first send that accepts what was inferred resolves the + // argument; a later incompatible send is reported by the second pass + if (!$acceptsAnything && !$sent->isSuperTypeOf($initial)->yes()) { + continue; + } + + if (TemplateArgumentStats::$enabled) { + TemplateArgumentStats::increment('resolvedBySend'); + } + return $sent; + } + + if ($covariantFallback !== null) { + if (TemplateArgumentStats::$enabled) { + TemplateArgumentStats::increment('resolvedBySend'); + } + return $covariantFallback; + } + } + + $parts = $lowerBounds; + // a never initial adds nothing to a union and would otherwise hide the + // "nothing was inferred" case below + if ($initial !== null && !$initial instanceof NeverType) { + $parts[] = $initial; + } + if (count($parts) === 0) { + if ($observation['unconstrainingSend']) { + // sent to a target that accepts anything: the object is in use, so + // the template's bound is what is known about the argument - never + // would make every later read of it an error + return $observation['marker']->getTemplate()->getBound(); + } + if ($initial instanceof NeverType) { + return $initial; + } + if (TemplateArgumentStats::$enabled) { + TemplateArgumentStats::increment('resolvedUnconstrained'); + } + + return TemplateArgumentFrame::resolveUnconstrained($observation['marker']->getSite(), $observation['marker']->getTemplate(), $this->resolve(...)); + } + + if (TemplateArgumentStats::$enabled) { + TemplateArgumentStats::increment(count($lowerBounds) > 0 ? 'resolvedWithLowerBounds' : 'resolvedToInitial'); + } + return TypeCombinator::union(...$parts); + } + + /** + * The resolved type of a template argument of the site, or null for a site + * this solve and its parent context never observed. + */ + private function resolve(Expr $site, string $templateName): ?Type + { + $key = self::key($site, $templateName); + if (array_key_exists($key, $this->resolutions)) { + return $this->resolutions[$key]; + } + + if ($this->parent !== null) { + return $this->parent->resolve($site, $templateName); + } + + return null; + } + + private static function key(Expr $site, string $templateName): string + { + return spl_object_id($site) . '#' . $templateName; + } + +} diff --git a/src/Analyser/Generics/TemplateArgumentStats.php b/src/Analyser/Generics/TemplateArgumentStats.php new file mode 100644 index 00000000000..168c506a61e --- /dev/null +++ b/src/Analyser/Generics/TemplateArgumentStats.php @@ -0,0 +1,108 @@ + */ + private static array $counters = [ + 'bodiesWalked' => 0, + 'bodiesWithSites' => 0, + 'sitesCreated' => 0, + 'statementsTotal' => 0, + 'statementsReplayed' => 0, + 'statementsReWalked' => 0, + 'earlyExits' => 0, + 'resolvedBySend' => 0, + 'resolvedWithLowerBounds' => 0, + 'resolvedToInitial' => 0, + 'resolvedUnconstrained' => 0, + ]; + + public static function enableFromEnvironment(): void + { + $value = getenv('PHPSTAN_TEMPLATE_CLAMP_STATS'); + if (in_array($value, [false, ''], true)) { + return; + } + + self::$enabled = true; + self::$outputFile = $value === '1' ? null : $value; + if (self::$shutdownRegistered) { + return; + } + + self::$shutdownRegistered = true; + register_shutdown_function(static function (): void { + self::dump(); + }); + } + + public static function increment(string $counter, int $by = 1): void + { + self::$counters[$counter] += $by; + } + + /** @return array */ + public static function getCounters(): array + { + return self::$counters; + } + + public static function reset(): void + { + foreach (array_keys(self::$counters) as $name) { + self::$counters[$name] = 0; + } + } + + private static function dump(): void + { + if (array_sum(self::$counters) === 0) { + return; + } + + $lines = ''; + foreach (self::$counters as $name => $value) { + $lines .= sprintf('%s=%d', $name, $value) . PHP_EOL; + } + + $output = '[template-arguments stats]' . PHP_EOL . $lines; + if (self::$outputFile !== null) { + file_put_contents(self::$outputFile, $output, FILE_APPEND | LOCK_EX); + return; + } + + fwrite(STDERR, $output); + } + +} diff --git a/src/Analyser/InternalScopeFactory.php b/src/Analyser/InternalScopeFactory.php index 99a6ea34a9e..ee2c58961c0 100644 --- a/src/Analyser/InternalScopeFactory.php +++ b/src/Analyser/InternalScopeFactory.php @@ -2,6 +2,8 @@ namespace PHPStan\Analyser; +use PHPStan\Analyser\Generics\TemplateArgumentConstraints; +use PHPStan\Analyser\Generics\TemplateArgumentFrame; use PHPStan\Reflection\FunctionReflection; use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\ParameterReflection; @@ -37,6 +39,8 @@ public function create( bool $afterExtractCall = false, ?MutatingScope $parentScope = null, bool $nativeTypesPromoted = false, + ?TemplateArgumentFrame $templateArgumentFrame = null, + ?TemplateArgumentConstraints $templateArgumentConstraints = null, ): MutatingScope; public function toNodeCallbackScopeFactory(): self; diff --git a/src/Analyser/InternalStatementResult.php b/src/Analyser/InternalStatementResult.php index b10cccd7d1f..092fd51a277 100644 --- a/src/Analyser/InternalStatementResult.php +++ b/src/Analyser/InternalStatementResult.php @@ -25,6 +25,12 @@ public function __construct( private array $endStatements = [], ) { + foreach ($exitPoints as $exitPoint) { + $this->scope = $this->scope->addTemplateArgumentConstraints($exitPoint->getScope()->getTemplateArgumentConstraints()); + } + foreach ($endStatements as $endStatement) { + $this->scope = $this->scope->addTemplateArgumentConstraints($endStatement->getResult()->getScope()->getTemplateArgumentConstraints()); + } } public function toPublic(): StatementResult diff --git a/src/Analyser/LazyInternalScopeFactory.php b/src/Analyser/LazyInternalScopeFactory.php index 1639021edac..aa627a186f1 100644 --- a/src/Analyser/LazyInternalScopeFactory.php +++ b/src/Analyser/LazyInternalScopeFactory.php @@ -3,6 +3,8 @@ namespace PHPStan\Analyser; use PhpParser\Node; +use PHPStan\Analyser\Generics\TemplateArgumentConstraints; +use PHPStan\Analyser\Generics\TemplateArgumentFrame; use PHPStan\DependencyInjection\Container; use PHPStan\DependencyInjection\ExtensionsCollection; use PHPStan\DependencyInjection\GenerateFactory; @@ -85,6 +87,8 @@ public function create( bool $afterExtractCall = false, ?MutatingScope $parentScope = null, bool $nativeTypesPromoted = false, + ?TemplateArgumentFrame $templateArgumentFrame = null, + ?TemplateArgumentConstraints $templateArgumentConstraints = null, ): MutatingScope { $className = MutatingScope::class; @@ -136,6 +140,8 @@ public function create( $afterExtractCall, $parentScope, $nativeTypesPromoted, + $templateArgumentFrame, + $templateArgumentConstraints, ); } diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 96f4301f6df..a77adf8bcfd 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -24,6 +24,8 @@ use PhpParser\Node\Stmt\Function_; use PhpParser\NodeFinder; use PHPStan\Analyser\ExprHandler\Helper\ClosureTypeResolver; +use PHPStan\Analyser\Generics\TemplateArgumentConstraints; +use PHPStan\Analyser\Generics\TemplateArgumentFrame; use PHPStan\Analyser\Traverser\TransformStaticTypeTraverser; use PHPStan\Collectors\Collector; use PHPStan\DependencyInjection\Container; @@ -132,6 +134,7 @@ use function is_string; use function ltrim; use function md5; +use function preg_match; use function spl_object_id; use function sprintf; use function str_starts_with; @@ -210,6 +213,8 @@ public function __construct( protected bool $afterExtractCall = false, private ?self $parentScope = null, public bool $nativeTypesPromoted = false, + protected ?TemplateArgumentFrame $templateArgumentFrame = null, + protected ?TemplateArgumentConstraints $templateArgumentConstraints = null, ) { if ($namespace === '') { @@ -242,6 +247,8 @@ public function toNodeCallbackScope(): self $this->afterExtractCall, $this->parentScope, $this->nativeTypesPromoted, + $this->templateArgumentFrame, + $this->templateArgumentConstraints, ); if ($nodeCallbackScope instanceof NodeCallbackScope) { $nodeCallbackScope->seedWalkScope($this); @@ -309,6 +316,8 @@ public function enterDeclareStrictTypes(): self null, $this->expressionTypes, $this->nativeExpressionTypes, + templateArgumentFrame: $this->templateArgumentFrame, + templateArgumentConstraints: $this->templateArgumentConstraints, ); } @@ -394,6 +403,8 @@ public function rememberConstructorScope(): self $this->afterExtractCall, $this->parentScope, $this->nativeTypesPromoted, + $this->templateArgumentFrame, + $this->templateArgumentConstraints, ); } @@ -511,6 +522,8 @@ public function afterExtractCall(): self true, $this->parentScope, $this->nativeTypesPromoted, + $this->templateArgumentFrame, + $this->templateArgumentConstraints, ); } @@ -577,6 +590,8 @@ public function afterClearstatcacheCall(): self $this->afterExtractCall, $this->parentScope, $this->nativeTypesPromoted, + $this->templateArgumentFrame, + $this->templateArgumentConstraints, ); } @@ -675,6 +690,8 @@ public function afterOpenSslCall(string $openSslFunctionName): self $this->afterExtractCall, $this->parentScope, $this->nativeTypesPromoted, + $this->templateArgumentFrame, + $this->templateArgumentConstraints, ); } @@ -715,6 +732,8 @@ public function invalidateVolatileExpressions(): self $this->afterExtractCall, $this->parentScope, $this->nativeTypesPromoted, + $this->templateArgumentFrame, + $this->templateArgumentConstraints, ); } @@ -750,6 +769,8 @@ public function invalidateExistenceCheckExpressions(array $functionNames, ?strin $this->afterExtractCall, $this->parentScope, $this->nativeTypesPromoted, + $this->templateArgumentFrame, + $this->templateArgumentConstraints, ); } @@ -1022,6 +1043,8 @@ public function withAnonymousFunctionReflection(ClosureType $anonymousFunctionRe $this->afterExtractCall, $this->parentScope, $this->nativeTypesPromoted, + $this->templateArgumentFrame, + $this->templateArgumentConstraints, ); } @@ -1116,6 +1139,8 @@ public function duplicateWith( $afterExtractCall, $this->parentScope, $this->nativeTypesPromoted, + $this->templateArgumentFrame, + $this->templateArgumentConstraints, ); } @@ -1441,6 +1466,190 @@ public function getCurrentExpressionResultStorage(): ?ExpressionResultStorage return $this->expressionResultStorageStack->getCurrent(); } + public function withTemplateArgumentFrame(?TemplateArgumentFrame $frame): self + { + $scope = $this->withoutMemoizedTypes(); + $scope->templateArgumentFrame = $frame; + return $scope; + } + + public function getCurrentTemplateArgumentFrame(): ?TemplateArgumentFrame + { + return $this->templateArgumentFrame; + } + + public function getTemplateArgumentConstraints(): ?TemplateArgumentConstraints + { + return $this->templateArgumentConstraints; + } + + public function withTemplateArgumentConstraints(?TemplateArgumentConstraints $constraints): self + { + if ($constraints === $this->templateArgumentConstraints) { + return $this; + } + $scope = clone $this; + $scope->nodeCallbackScope = null; + $scope->scopeOutOfFirstLevelStatement = null; + $scope->scopeWithPromotedNativeTypes = null; + $scope->templateArgumentConstraints = $constraints; + return $scope; + } + + /** Inference facts join independently of variable-state convergence and branch termination. */ + public function addTemplateArgumentConstraints(?TemplateArgumentConstraints $constraints): self + { + if ($constraints === null || $constraints->isEmpty()) { + return $this; + } + + return $this->withTemplateArgumentConstraints($this->templateArgumentConstraints === null ? $constraints : $this->templateArgumentConstraints->merge($constraints)); + } + + /** + * A copy of this scope without its memoized type answers: the recorded + * entry scope of a statement the second pass re-walks answered questions + * during the observation pass with unresolved template arguments in them. + */ + public function withoutMemoizedTypes(): self + { + return $this->duplicateWith( + $this->expressionTypes, + $this->nativeExpressionTypes, + $this->conditionalExpressions, + $this->currentlyAssignedExpressions, + $this->currentlyAllowedUndefinedExpressions, + $this->inFunctionCallsStack, + $this->inFirstLevelStatement, + $this->afterExtractCall, + ); + } + + /** + * The variables rooting the tracked expressions whose state differs between + * this scope and $other - a statement mentioning none of them walks the same + * on both - or null when a differing entry has no variable root (a static + * property, a class constant fetch). + * + * @return list|null + */ + public function getDifferingVariableRoots(self $other): ?array + { + $roots = []; + $tables = [ + [$this->expressionTypes, $other->expressionTypes], + [$this->nativeExpressionTypes, $other->nativeExpressionTypes], + ]; + foreach ($tables as [$ours, $theirs]) { + foreach ($ours as $key => $holder) { + $theirHolder = $theirs[$key] ?? null; + if ($theirHolder !== null && ($theirHolder === $holder || $holder->equals($theirHolder))) { + continue; + } + $root = self::getVariableRootOfExpressionKey($key); + if ($root === null) { + return null; + } + $roots[$root] = true; + } + foreach ($theirs as $key => $holder) { + if (isset($ours[$key])) { + continue; + } + $root = self::getVariableRootOfExpressionKey($key); + if ($root === null) { + return null; + } + $roots[$root] = true; + } + } + $conditionalTables = [ + [$this->conditionalExpressions, $other->conditionalExpressions], + [$other->conditionalExpressions, $this->conditionalExpressions], + ]; + foreach ($conditionalTables as [$ours, $theirs]) { + foreach ($ours as $key => $holders) { + if (isset($theirs[$key]) && $theirs[$key] === $holders) { + continue; + } + $root = self::getVariableRootOfExpressionKey($key); + if ($root === null) { + return null; + } + $roots[$root] = true; + } + } + + return array_keys($roots); + } + + private static function getVariableRootOfExpressionKey(string $key): ?string + { + if (preg_match('/^\$([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)/', $key, $matches) !== 1) { + return null; + } + + return $matches[1]; + } + + /** + * This scope after a statement whose recorded walk stands: the entries the + * statement changed or removed between its recorded entry and exit scopes + * are carried over, everything else keeps this scope's state. + */ + public function withRecordedStatementDelta(self $recordedEntry, self $recordedExit): self + { + $conditionalExpressions = $this->conditionalExpressions; + foreach ($recordedExit->conditionalExpressions as $key => $holders) { + if (isset($recordedEntry->conditionalExpressions[$key]) && $recordedEntry->conditionalExpressions[$key] === $holders) { + continue; + } + $conditionalExpressions[$key] = $holders; + } + foreach (array_keys($recordedEntry->conditionalExpressions) as $key) { + if (isset($recordedExit->conditionalExpressions[$key])) { + continue; + } + unset($conditionalExpressions[$key]); + } + + return $this->duplicateWith( + self::applyRecordedHolderDelta($this->expressionTypes, $recordedEntry->expressionTypes, $recordedExit->expressionTypes), + self::applyRecordedHolderDelta($this->nativeExpressionTypes, $recordedEntry->nativeExpressionTypes, $recordedExit->nativeExpressionTypes), + $conditionalExpressions, + [], + [], + [], + $this->inFirstLevelStatement, + $recordedExit->afterExtractCall, + ); + } + + /** + * @param array $current + * @param array $recordedEntry + * @param array $recordedExit + * @return array + */ + private static function applyRecordedHolderDelta(array $current, array $recordedEntry, array $recordedExit): array + { + foreach ($recordedExit as $key => $holder) { + $entryHolder = $recordedEntry[$key] ?? null; + if ($entryHolder !== null && ($entryHolder === $holder || $entryHolder->equals($holder))) { + continue; + } + $current[$key] = $holder; + } + foreach (array_keys($recordedEntry) as $key) { + if (isset($recordedExit[$key])) { + continue; + } + unset($current[$key]); + } + + return $current; + } + /** @api */ public function getNativeType(Expr $expr): Type { @@ -1520,6 +1729,8 @@ private function promoteNativeTypes(): self $this->afterExtractCall, $this->parentScope, true, + templateArgumentFrame: $this->templateArgumentFrame, + templateArgumentConstraints: $this->templateArgumentConstraints, ); } @@ -1633,6 +1844,8 @@ public function pushInFunctionCall($reflection, ?ParameterReflection $parameter, $this->afterExtractCall, $this->parentScope, $this->nativeTypesPromoted, + $this->templateArgumentFrame, + $this->templateArgumentConstraints, ); if ($rememberTypes) { @@ -1664,6 +1877,8 @@ public function popInFunctionCall(): self $this->afterExtractCall, $this->parentScope, $this->nativeTypesPromoted, + $this->templateArgumentFrame, + $this->templateArgumentConstraints, ); $parentScope->resolvedTypes = $this->resolvedTypes; @@ -1748,6 +1963,8 @@ public function enterClass(ClassReflection $classReflection): self [], false, $classReflection->isAnonymous() ? $this : null, + templateArgumentFrame: $this->templateArgumentFrame, + templateArgumentConstraints: $this->templateArgumentConstraints, ); } @@ -1769,6 +1986,8 @@ public function enterTrait(ClassReflection $traitReflection): self [], $this->inClosureBindScopeClasses, $this->anonymousFunctionReflection, + templateArgumentFrame: $this->templateArgumentFrame, + templateArgumentConstraints: $this->templateArgumentConstraints, ); } @@ -2132,6 +2351,8 @@ private function enterFunctionLike( array_merge($this->getConstantTypes(), $expressionTypes), array_merge($this->getNativeConstantTypes(), $nativeExpressionTypes), $conditionalTypes, + templateArgumentFrame: $this->templateArgumentFrame, + templateArgumentConstraints: $this->templateArgumentConstraints, ); } @@ -2143,6 +2364,8 @@ public function enterNamespace(string $namespaceName): self $this->isDeclareStrictTypes(), null, $namespaceName, + templateArgumentFrame: $this->templateArgumentFrame, + templateArgumentConstraints: $this->templateArgumentConstraints, ); } @@ -2179,6 +2402,8 @@ public function enterClosureBind(?Type $thisType, ?Type $nativeThisType, array $ $this->conditionalExpressions, $scopeClasses, $this->anonymousFunctionReflection, + templateArgumentFrame: $this->templateArgumentFrame, + templateArgumentConstraints: $this->templateArgumentConstraints, ); } @@ -2208,6 +2433,8 @@ public function restoreOriginalScopeAfterClosureBind(self $originalScope): self $this->conditionalExpressions, $originalScope->inClosureBindScopeClasses, $this->anonymousFunctionReflection, + templateArgumentFrame: $this->templateArgumentFrame, + templateArgumentConstraints: $this->templateArgumentConstraints, ); } @@ -2254,6 +2481,8 @@ public function restoreThis(self $restoreThisScope): self $this->afterExtractCall, $this->parentScope, $this->nativeTypesPromoted, + $this->templateArgumentFrame, + $this->templateArgumentConstraints, ); } @@ -2275,6 +2504,8 @@ public function enterClosureCall(Type $thisType, Type $nativeThisType): self $this->conditionalExpressions, $thisType->getObjectClassNames(), $this->anonymousFunctionReflection, + templateArgumentFrame: $this->templateArgumentFrame, + templateArgumentConstraints: $this->templateArgumentConstraints, ); } @@ -2306,6 +2537,8 @@ public function withClosureBindScopeClasses(array $scopeClasses): self $this->afterExtractCall, $this->parentScope, $this->nativeTypesPromoted, + $this->templateArgumentFrame, + $this->templateArgumentConstraints, ); } @@ -2341,6 +2574,8 @@ public function enterAnonymousFunction( false, $this, $this->nativeTypesPromoted, + $this->templateArgumentFrame, + $this->templateArgumentConstraints, ); } @@ -2488,6 +2723,8 @@ public function enterAnonymousFunctionWithoutReflection( false, $this, $this->nativeTypesPromoted, + $this->templateArgumentFrame, + $this->templateArgumentConstraints, ); } @@ -2564,6 +2801,8 @@ public function enterArrowFunction(Expr\ArrowFunction $arrowFunction, ?array $ca $scope->afterExtractCall, $scope->parentScope, $this->nativeTypesPromoted, + $this->templateArgumentFrame, + $this->templateArgumentConstraints, ); } @@ -2611,6 +2850,8 @@ public function enterArrowFunctionWithoutReflection(Expr\ArrowFunction $arrowFun $arrowFunctionScope->afterExtractCall, $arrowFunctionScope->parentScope, $this->nativeTypesPromoted, + $this->templateArgumentFrame, + $this->templateArgumentConstraints, ); } @@ -2869,6 +3110,8 @@ public function enterExpressionAssign(Expr $expr, bool $isPlainWrite = true): se $this->afterExtractCall, $this->parentScope, $this->nativeTypesPromoted, + $this->templateArgumentFrame, + $this->templateArgumentConstraints, ); $scope->resolvedTypes = $this->resolvedTypes; @@ -2898,6 +3141,8 @@ public function exitExpressionAssign(Expr $expr): self $this->afterExtractCall, $this->parentScope, $this->nativeTypesPromoted, + $this->templateArgumentFrame, + $this->templateArgumentConstraints, ); $scope->resolvedTypes = $this->resolvedTypes; @@ -2957,6 +3202,8 @@ public function setAllowedUndefinedExpression(Expr $expr): self $this->afterExtractCall, $this->parentScope, $this->nativeTypesPromoted, + $this->templateArgumentFrame, + $this->templateArgumentConstraints, ); $scope->resolvedTypes = $this->resolvedTypes; @@ -2986,6 +3233,8 @@ public function unsetAllowedUndefinedExpression(Expr $expr): self $this->afterExtractCall, $this->parentScope, $this->nativeTypesPromoted, + $this->templateArgumentFrame, + $this->templateArgumentConstraints, ); $scope->resolvedTypes = $this->resolvedTypes; @@ -3343,7 +3592,7 @@ private function resolveScopeStateType(Expr $expr, bool $native): Type // resolves it (Collection::first()'s TFirstDefault -> null) $variant = ParametersAcceptorSelector::selectFromArgs($this, [], $methodReflection->getVariants(), $methodReflection->getNamedArgumentsVariants()); - return $native && $variant instanceof ExtendedParametersAcceptor ? $variant->getNativeReturnType() : $variant->getReturnType(); + return $native && $variant instanceof ExtendedParametersAcceptor ? $variant->getNativeReturnType() : TemplateArgumentFrame::returnTypeOfCall($variant, $this, $expr, true); } // position-independent constant expressions (isset()/?? dimensions and @@ -3395,6 +3644,8 @@ private function openSpecificationScope(): self $this->afterExtractCall, $this->parentScope, $this->nativeTypesPromoted, + $this->templateArgumentFrame, + $this->templateArgumentConstraints, ); } @@ -3999,6 +4250,8 @@ public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes): self $scope->afterExtractCall, $scope->parentScope, $scope->nativeTypesPromoted, + $scope->templateArgumentFrame, + $scope->templateArgumentConstraints, ); } @@ -4117,6 +4370,11 @@ public function isInFirstLevelStatement(): bool } public function mergeWith(?self $otherScope, bool $preserveVacuousConditionals = false): self + { + return $this->mergeWithVariableState($otherScope, $preserveVacuousConditionals)->addTemplateArgumentConstraints($otherScope?->getTemplateArgumentConstraints()); + } + + private function mergeWithVariableState(?self $otherScope, bool $preserveVacuousConditionals = false): self { if ($otherScope === null || $this === $otherScope) { return $this; @@ -4347,6 +4605,8 @@ public function processFinallyScope(self $finallyScope, self $originalFinallySco $this->afterExtractCall, $this->parentScope, $this->nativeTypesPromoted, + $this->templateArgumentFrame, + $this->templateArgumentConstraints, ); } @@ -4443,6 +4703,8 @@ public function processClosureScope( $this->afterExtractCall, $this->parentScope, $this->nativeTypesPromoted, + $this->templateArgumentFrame, + $this->templateArgumentConstraints, ); } @@ -4492,10 +4754,17 @@ public function processAlwaysIterableForeachScopeWithoutPollute(self $finalScope $this->afterExtractCall, $this->parentScope, $this->nativeTypesPromoted, + $this->templateArgumentFrame, + $this->templateArgumentConstraints, ); } public function generalizeWith(self $otherScope): self + { + return $this->generalizeWithVariableState($otherScope)->addTemplateArgumentConstraints($otherScope->getTemplateArgumentConstraints()); + } + + private function generalizeWithVariableState(self $otherScope): self { $variableTypeHolders = $this->generalizeVariableTypeHolders( $this->expressionTypes, @@ -4523,6 +4792,8 @@ public function generalizeWith(self $otherScope): self $this->afterExtractCall, $this->parentScope, $this->nativeTypesPromoted, + $this->templateArgumentFrame, + $this->templateArgumentConstraints, ); } diff --git a/src/Analyser/NodeCallbackScope.php b/src/Analyser/NodeCallbackScope.php index e544c639e11..9e0d4070b8f 100644 --- a/src/Analyser/NodeCallbackScope.php +++ b/src/Analyser/NodeCallbackScope.php @@ -75,6 +75,8 @@ public function toWalkScope(): MutatingScope $this->afterExtractCall, $this->getParentScope(), $this->nativeTypesPromoted, + $this->templateArgumentFrame, + $this->templateArgumentConstraints, ); } diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 7a00057e9c4..e5cf1a91b50 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -34,6 +34,11 @@ use PHPStan\Analyser\ExprHandler\Helper\ClosureTypeResolver; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; use PHPStan\Analyser\ExprHandler\Helper\VirtualExprResultHelper; +use PHPStan\Analyser\Generics\TemplateArgumentConstraints; +use PHPStan\Analyser\Generics\TemplateArgumentFrame; +use PHPStan\Analyser\Generics\TemplateArgumentObserver; +use PHPStan\Analyser\Generics\TemplateArgumentResolver; +use PHPStan\Analyser\Generics\TemplateArgumentStats; use PHPStan\DependencyInjection\AutowiredExtensions; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; @@ -76,6 +81,7 @@ use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Reflection\Php\PhpMethodReflection; use PHPStan\Reflection\ReflectionProvider; +use PHPStan\Reflection\ResolvedFunctionVariant; use PHPStan\Rules\Properties\ReadWritePropertiesExtension; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; @@ -85,6 +91,9 @@ use PHPStan\Type\FunctionParameterClosureThisExtension; use PHPStan\Type\FunctionParameterClosureTypeExtension; use PHPStan\Type\FunctionParameterOutTypeExtension; +use PHPStan\Type\Generic\TemplateTypeHelper; +use PHPStan\Type\Generic\TemplateTypeMap; +use PHPStan\Type\Generic\TemplateTypeVariance; use PHPStan\Type\MethodParameterClosureThisExtension; use PHPStan\Type\MethodParameterClosureTypeExtension; use PHPStan\Type\MethodParameterOutTypeExtension; @@ -113,6 +122,7 @@ use function count; use function get_class; use function getenv; +use function implode; use function in_array; use function is_array; use function is_int; @@ -162,6 +172,9 @@ class NodeScopeResolver /** Whether the PHPSTAN_GUARD_NW diagnostic is enabled (cached from the env). */ public static bool $guardNewWorld = false; + /** PHPSTAN_TEMPLATE_ARGUMENTS_DEBUG=1 prints every second-pass re-walk/replay decision. */ + private static bool $debugTemplateArguments = false; + /** * spl_object_id => true of every Expr in the file's parsed AST. Populated * only when the PHPSTAN_GUARD_NW diagnostic is enabled, so the guards can @@ -196,6 +209,8 @@ class NodeScopeResolver */ public function __construct( private readonly Container $container, + private readonly TemplateArgumentObserver $templateArgumentObserver, + private readonly TemplateArgumentResolver $templateArgumentResolver, private readonly ReflectionProvider $reflectionProvider, #[AutowiredExtensions(of: FunctionParameterOutTypeExtension::class)] private readonly ExtensionsCollection $functionParameterOutTypeExtensions, @@ -229,9 +244,13 @@ public function __construct( #[AutowiredParameter] private readonly bool $treatPhpDocTypesAsCertain, private readonly ExpressionResultFactory $expressionResultFactory, + #[AutowiredParameter(ref: '%featureToggles.unresolvedTemplateArguments%')] + private readonly bool $unresolvedTemplateArguments, ) { self::$guardNewWorld = getenv('PHPSTAN_GUARD_NW') === '1'; + self::$debugTemplateArguments = getenv('PHPSTAN_TEMPLATE_ARGUMENTS_DEBUG') === '1'; + TemplateArgumentStats::enableFromEnvironment(); } /** @@ -644,139 +663,449 @@ private function doProcessStmtNodes( StatementContext $context, ): InternalStatementResult { - $exitPoints = []; - $throwPoints = []; - $impurePoints = []; - $alreadyTerminated = false; - $hasYield = false; $stmtCount = count($stmts); $shouldCheckLastStatement = $parentNode instanceof Node\Stmt\Function_ || $parentNode instanceof Node\Stmt\ClassMethod || $parentNode instanceof PropertyHookStatementNode || $parentNode instanceof Expr\Closure; + if ( + $shouldCheckLastStatement + && $stmtCount > 0 + && $this->unresolvedTemplateArguments + && !$nodeCallback instanceof RecordingNodeCallback + && !$nodeCallback instanceof NoopNodeCallback + ) { + return $this->processBodyStmtNodesTwoPass($parentNode, $stmts, $scope, $storage, $nodeCallback, $context); + } + + $state = new StatementListWalkState($scope); foreach ($stmts as $i => $stmt) { - if ($alreadyTerminated && !($stmt instanceof Node\Stmt\Function_ || $stmt instanceof Node\Stmt\ClassLike || $stmt instanceof Node\Stmt\Label)) { - continue; + $this->processStatementStep($parentNode, $stmts, $i, $stmt, $state, $storage, $nodeCallback, $context, $shouldCheckLastStatement); + } + + $statementResult = $state->toResult(); + if ($stmtCount === 0 && $shouldCheckLastStatement) { + $returnTypeNode = $parentNode->getReturnType(); + if ($parentNode instanceof Expr\Closure) { + $parentNode = new Node\Stmt\Expression($parentNode, $parentNode->getAttributes()); } + // the body is empty - the statement above is a synthetic wrapper around + // the closure, not an expression statement that was processed + $this->callNodeCallback($nodeCallback, new ExecutionEndNode( + $parentNode, + $statementResult->toPublic(), + $returnTypeNode !== null, + ), $scope, $storage); + } - $isLast = $i === $stmtCount - 1; + return $statementResult; + } - $nestedLabelNames = $stmt->getAttribute(GotoLabelVisitor::NESTED_BACKWARD_GOTO_LABELS_ATTRIBUTE); - if ($nestedLabelNames !== null && $context->isTopLevel()) { - $scope = $this->resolveBackwardGotoScope( - $parentNode, - [$stmt], - $scope, - $storage, - $context->enterDeep(), - static fn (string $name): bool => isset($nestedLabelNames[$name]), - false, - ); - } + /** + * One statement of a statement list, advancing $state past it. + * + * @param Node\Stmt[] $stmts + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + private function processStatementStep( + Node $parentNode, + array $stmts, + int $i, + Node\Stmt $stmt, + StatementListWalkState $state, + ExpressionResultStorage $storage, + callable $nodeCallback, + StatementContext $context, + bool $shouldCheckLastStatement, + ): void + { + if ($state->alreadyTerminated && !($stmt instanceof Node\Stmt\Function_ || $stmt instanceof Node\Stmt\ClassLike || $stmt instanceof Node\Stmt\Label)) { + return; + } - $statementResult = $this->processStmtNode( - $stmt, - $scope, + $isLast = $i === count($stmts) - 1; + + $nestedLabelNames = $stmt->getAttribute(GotoLabelVisitor::NESTED_BACKWARD_GOTO_LABELS_ATTRIBUTE); + if ($nestedLabelNames !== null && $context->isTopLevel()) { + $state->scope = $this->resolveBackwardGotoScope( + $parentNode, + [$stmt], + $state->scope, $storage, - $nodeCallback, - $context, + $context->enterDeep(), + static fn (string $name): bool => isset($nestedLabelNames[$name]), + false, ); - $scope = $statementResult->getScope(); - $hasYield = $hasYield || $statementResult->hasYield(); + } - if ($stmt instanceof Node\Stmt\Label) { - $labelName = $stmt->name->toString(); + $statementResult = $this->processStmtNode( + $stmt, + $state->scope, + $storage, + $nodeCallback, + $context, + ); + $state->scope = $statementResult->getScope(); + $state->hasYield = $state->hasYield || $statementResult->hasYield(); - [$scope, $alreadyTerminated, $exitPoints] = $this->mergeForwardGotoExitPoints( - $labelName, - $scope, - $alreadyTerminated, - $exitPoints, - ); + if ($stmt instanceof Node\Stmt\Label) { + $labelName = $stmt->name->toString(); - if ($alreadyTerminated) { - continue; - } + [$state->scope, $state->alreadyTerminated, $state->exitPoints] = $this->mergeForwardGotoExitPoints( + $labelName, + $state->scope, + $state->alreadyTerminated, + $state->exitPoints, + ); - if ($stmt->getAttribute(GotoLabelVisitor::HAS_BACKWARD_GOTO_ATTRIBUTE) === true && $context->isTopLevel()) { - $scope = $this->resolveBackwardGotoScope( - $parentNode, - array_slice($stmts, $i + 1), - $scope, - $storage, - $context->enterDeep(), - static fn (string $name): bool => $name === $labelName, - true, - ); - } + if ($state->alreadyTerminated) { + return; } - if ($shouldCheckLastStatement && $isLast) { - $endStatements = $statementResult->getEndStatements(); - if (count($endStatements) > 0) { - foreach ($endStatements as $endStatement) { - $endStatementResult = $endStatement->getResult(); - $this->callNodeCallback($nodeCallback, new ExecutionEndNode( - $endStatement->getStatement(), - (new InternalStatementResult( - $endStatementResult->getScope(), - $hasYield, - $endStatementResult->isAlwaysTerminating(), - $endStatementResult->getExitPoints(), - $endStatementResult->getThrowPoints(), - $endStatementResult->getImpurePoints(), - ))->toPublic(), - $parentNode->getReturnType() !== null, - $this->readEndStatementExprResult($endStatement->getStatement(), $storage), - ), $endStatementResult->getScope(), $storage); - } - } else { + if ($stmt->getAttribute(GotoLabelVisitor::HAS_BACKWARD_GOTO_ATTRIBUTE) === true && $context->isTopLevel()) { + $state->scope = $this->resolveBackwardGotoScope( + $parentNode, + array_slice($stmts, $i + 1), + $state->scope, + $storage, + $context->enterDeep(), + static fn (string $name): bool => $name === $labelName, + true, + ); + } + } + + if ($shouldCheckLastStatement && $isLast) { + $hasDeclaredReturnType = ($parentNode instanceof Node\FunctionLike || $parentNode instanceof PropertyHookStatementNode) + && $parentNode->getReturnType() !== null; + $endStatements = $statementResult->getEndStatements(); + if (count($endStatements) > 0) { + foreach ($endStatements as $endStatement) { + $endStatementResult = $endStatement->getResult(); $this->callNodeCallback($nodeCallback, new ExecutionEndNode( - $stmt, + $endStatement->getStatement(), (new InternalStatementResult( - $scope, - $hasYield, - $statementResult->isAlwaysTerminating(), - $statementResult->getExitPoints(), - $statementResult->getThrowPoints(), - $statementResult->getImpurePoints(), + $endStatementResult->getScope(), + $state->hasYield, + $endStatementResult->isAlwaysTerminating(), + $endStatementResult->getExitPoints(), + $endStatementResult->getThrowPoints(), + $endStatementResult->getImpurePoints(), ))->toPublic(), - $parentNode->getReturnType() !== null, - $this->readEndStatementExprResult($stmt, $storage), - ), $scope, $storage); + $hasDeclaredReturnType, + $this->readEndStatementExprResult($endStatement->getStatement(), $storage), + ), $endStatementResult->getScope(), $storage); } + } else { + $this->callNodeCallback($nodeCallback, new ExecutionEndNode( + $stmt, + (new InternalStatementResult( + $state->scope, + $state->hasYield, + $statementResult->isAlwaysTerminating(), + $statementResult->getExitPoints(), + $statementResult->getThrowPoints(), + $statementResult->getImpurePoints(), + ))->toPublic(), + $hasDeclaredReturnType, + $this->readEndStatementExprResult($stmt, $storage), + ), $state->scope, $storage); + } + } + + $state->exitPoints = array_merge($state->exitPoints, $statementResult->getExitPoints()); + $state->throwPoints = array_merge($state->throwPoints, $statementResult->getThrowPoints()); + $state->impurePoints = array_merge($state->impurePoints, $statementResult->getImpurePoints()); + + if ($state->alreadyTerminated || !$statementResult->isAlwaysTerminating()) { + return; + } + + $state->alreadyTerminated = true; + $nextStmts = $this->getNextUnreachableStatements(array_slice($stmts, $i + 1), $parentNode instanceof Node\Stmt\Namespace_); + $this->processUnreachableStatement($nextStmts, $state->scope, $storage, $nodeCallback); + } + + /** + * A function-like body under the unresolvedTemplateArguments toggle is + * walked in two passes. The observation pass walks every statement + * recording rule-facing emissions and threading immutable constraints + * through the scopes returned by expressions and statements. A body that + * created no unresolved template argument simply replays the recording. + * Otherwise TemplateArgumentResolver builds a new resolved frame for the + * second pass, which re-walks only the statements the resolutions can + * influence. Recorded scopes retain their original collection context. + * + * The outer gatherer frames (the method's return statements, execution + * ends, impure points) are suspended during the observation pass and fed + * by the replay and the re-walk, so each emission reaches them once. + * + * @param Node\Stmt[] $stmts + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + private function processBodyStmtNodesTwoPass( + Node $parentNode, + array $stmts, + MutatingScope $scope, + ExpressionResultStorage $storage, + callable $nodeCallback, + StatementContext $context, + ): InternalStatementResult + { + $statementStartTokenPositions = []; + foreach ($stmts as $stmt) { + $statementStartTokenPositions[] = $stmt->getStartTokenPos(); + } + $parentFrame = $scope->getCurrentTemplateArgumentFrame(); + $parentConstraints = $scope->getTemplateArgumentConstraints(); + $frame = new TemplateArgumentFrame($parentFrame); + if (TemplateArgumentStats::$enabled) { + TemplateArgumentStats::increment('bodiesWalked'); + TemplateArgumentStats::increment('statementsTotal', count($stmts)); + } + $scope = $scope->withTemplateArgumentFrame($frame)->withTemplateArgumentConstraints(null); + $recording = new RecordingNodeCallback(); + $state = new StatementListWalkState($scope); + /** @var list $entries the state and recording offset before each statement, plus the final ones */ + $entries = []; + $suspendedGatherers = $this->nodeGatherers; + $this->nodeGatherers = []; + try { + foreach ($stmts as $i => $stmt) { + $entries[$i] = [clone $state, $recording->count()]; + $this->processStatementStep($parentNode, $stmts, $i, $stmt, $state, $storage, $recording, $context, true); } + } finally { + $this->nodeGatherers = $suspendedGatherers; + } + $frame = $this->templateArgumentResolver->resolve($state->scope->getTemplateArgumentConstraints() ?? TemplateArgumentConstraints::createEmpty(), $parentFrame, $statementStartTokenPositions); + $stmtCount = count($stmts); + $entries[$stmtCount] = [clone $state, $recording->count()]; - $exitPoints = array_merge($exitPoints, $statementResult->getExitPoints()); - $throwPoints = array_merge($throwPoints, $statementResult->getThrowPoints()); - $impurePoints = array_merge($impurePoints, $statementResult->getImpurePoints()); + $firstSiteStatementIndex = $frame->firstSiteStatementIndex(); + if ($firstSiteStatementIndex === null) { + $this->replayRecordingRange($recording, 0, $recording->count(), $nodeCallback, $storage, $scope); - if ($alreadyTerminated || !$statementResult->isAlwaysTerminating()) { + $state->scope = $state->scope->withTemplateArgumentFrame($parentFrame)->withTemplateArgumentConstraints($parentConstraints); + return $state->toResult(); + } + + if (TemplateArgumentStats::$enabled) { + TemplateArgumentStats::increment('bodiesWithSites'); + TemplateArgumentStats::increment('statementsReplayed', $firstSiteStatementIndex); + } + // the second pass: the statements before the first site stand as + // recorded; from there on a statement is re-walked only when it + // created a site or mentions a variable whose tracked state the + // resolutions changed - the rest replay their recording and carry + // their recorded effect onto the re-walked scope + $this->replayRecordingRange($recording, 0, $entries[$firstSiteStatementIndex][1], $nodeCallback, $storage, $scope); + $hasLabels = false; + foreach ($stmts as $stmt) { + if (!$stmt instanceof Node\Stmt\Label && $stmt->getAttribute(GotoLabelVisitor::NESTED_BACKWARD_GOTO_LABELS_ATTRIBUTE) === null) { + continue; + } + $hasLabels = true; + break; + } + $state = clone $entries[$firstSiteStatementIndex][0]; + $state->scope = $state->scope->withTemplateArgumentFrame($frame)->withTemplateArgumentConstraints(null); + for ($i = $firstSiteStatementIndex; $i < $stmtCount; $i++) { + [$recordedEntry, $offset] = $entries[$i]; + [$recordedExit, $nextOffset] = $entries[$i + 1]; + $differingRoots = $state->alreadyTerminated === $recordedEntry->alreadyTerminated + ? $state->scope->getDifferingVariableRoots($recordedEntry->scope) + : null; + if ($differingRoots === [] && !$frame->hasSiteAtOrAfter($i)) { + // converged with the observation pass: the rest of its recording stands + if (TemplateArgumentStats::$enabled) { + TemplateArgumentStats::increment('earlyExits'); + TemplateArgumentStats::increment('statementsReplayed', $stmtCount - $i); + } + $this->replayRecordingRange($recording, $offset, $recording->count(), $nodeCallback, $storage, $scope); + $this->appendRecordedStatementResults($state, $recordedEntry, $entries[$stmtCount][0]); + $state->scope = $entries[$stmtCount][0]->scope; + + $state->scope = $state->scope->withTemplateArgumentFrame($parentFrame)->withTemplateArgumentConstraints($parentConstraints); + return $state->toResult(); + } + + $stmt = $stmts[$i]; + $reWalk = $differingRoots === null + || $hasLabels + || $frame->ownsSiteInStatement($i) + || $this->statementMentionsAnyVariable($stmt, $differingRoots); + if (self::$debugTemplateArguments) { + echo sprintf( + "[template-arguments] %s:%d statement %d: %s (differing: %s)\n", + $scope->getFile(), + $stmt->getStartLine(), + $i, + $reWalk ? 're-walk' : 'replay', + $differingRoots === null ? 'non-variable key' : implode(', ', $differingRoots), + ); + } + if ($reWalk) { + if (TemplateArgumentStats::$enabled) { + TemplateArgumentStats::increment('statementsReWalked'); + } + $this->processStatementStep($parentNode, $stmts, $i, $stmt, $state, $storage, $nodeCallback, $context, true); continue; } - $alreadyTerminated = true; - $nextStmts = $this->getNextUnreachableStatements(array_slice($stmts, $i + 1), $parentNode instanceof Node\Stmt\Namespace_); - $this->processUnreachableStatement($nextStmts, $scope, $storage, $nodeCallback); + if (TemplateArgumentStats::$enabled) { + TemplateArgumentStats::increment('statementsReplayed'); + } + $this->replayRecordingRange($recording, $offset, $nextOffset, $nodeCallback, $storage, $scope); + $this->appendRecordedStatementResults($state, $recordedEntry, $recordedExit); + $state->scope = $state->scope->withRecordedStatementDelta($recordedEntry->scope, $recordedExit->scope); } - $statementResult = new InternalStatementResult($scope, $hasYield, $alreadyTerminated, $exitPoints, $throwPoints, $impurePoints); - if ($stmtCount === 0 && $shouldCheckLastStatement) { - $returnTypeNode = $parentNode->getReturnType(); - if ($parentNode instanceof Expr\Closure) { - $parentNode = new Node\Stmt\Expression($parentNode, $parentNode->getAttributes()); + $state->scope = $state->scope->withTemplateArgumentFrame($parentFrame)->withTemplateArgumentConstraints($parentConstraints); + return $state->toResult(); + } + + /** + * The parameter type an argument is observed against: the declared one with + * the template types the call already decided substituted - the receiver's + * class-level arguments, a template an earlier argument inferred - while a + * template still open (ErrorType in the resolved map, typically the one this + * very argument decides) stays a TemplateType, which the observer ignores. + * The resolved parameter type would carry such a template's bound instead. + */ + private function findOriginalParameterType(ParametersAcceptor $acceptor, ParameterReflection $parameter): ?Type + { + if (!$acceptor instanceof ResolvedFunctionVariant) { + return $parameter->getType(); + } + $originalParameters = $acceptor->getOriginalParametersAcceptor()->getParameters(); + foreach ($acceptor->getParameters() as $index => $resolvedParameter) { + if ($resolvedParameter !== $parameter) { + continue; } - // the body is empty - the statement above is a synthetic wrapper around - // the closure, not an expression statement that was processed - $this->callNodeCallback($nodeCallback, new ExecutionEndNode( - $parentNode, - $statementResult->toPublic(), - $returnTypeNode !== null, - ), $scope, $storage); + if (!isset($originalParameters[$index])) { + return null; + } + $originalType = $originalParameters[$index]->getType(); + if (!$originalType->hasTemplateOrLateResolvableType()) { + return $originalType; + } + $decided = []; + foreach ($acceptor->getResolvedTemplateTypeMap()->getTypes() as $name => $type) { + if ($type instanceof ErrorType) { + continue; + } + $decided[$name] = $type; + } + + return TemplateTypeHelper::resolveTemplateTypes( + $originalType, + new TemplateTypeMap($decided), + $acceptor->getCallSiteVarianceMap(), + TemplateTypeVariance::createContravariant(), + ); } - return $statementResult; + return null; + } + + /** Carries what the recorded walk from $from to $to added onto $state. */ + private function appendRecordedStatementResults(StatementListWalkState $state, StatementListWalkState $from, StatementListWalkState $to): void + { + $state->hasYield = $state->hasYield || ($to->hasYield && !$from->hasYield); + $state->alreadyTerminated = $state->alreadyTerminated || ($to->alreadyTerminated && !$from->alreadyTerminated); + $state->exitPoints = array_merge($state->exitPoints, array_slice($to->exitPoints, count($from->exitPoints))); + $state->throwPoints = array_merge($state->throwPoints, array_slice($to->throwPoints, count($from->throwPoints))); + $state->impurePoints = array_merge($state->impurePoints, array_slice($to->impurePoints, count($from->impurePoints))); + } + + private const MENTIONED_VARIABLES_ATTRIBUTE = 'templateArgumentMentionedVariables'; + + /** + * @param list $variableNames + */ + private function statementMentionsAnyVariable(Node\Stmt $stmt, array $variableNames): bool + { + /** @var array{array, bool}|null $mentions */ + $mentions = $stmt->getAttribute(self::MENTIONED_VARIABLES_ATTRIBUTE); + if ($mentions === null) { + $names = []; + $mentionsEverything = false; + $this->collectMentionedVariables($stmt, $names, $mentionsEverything); + $mentions = [$names, $mentionsEverything]; + $stmt->setAttribute(self::MENTIONED_VARIABLES_ATTRIBUTE, $mentions); + } + [$names, $mentionsEverything] = $mentions; + if ($mentionsEverything) { + return true; + } + foreach ($variableNames as $variableName) { + if (isset($names[$variableName])) { + return true; + } + } + + return false; + } + + /** + * Every variable the statement can read or write - syntactically, so + * reads and writes alike - with `$this`; a closure body lives in its own + * scope, so only its use() clause (and its bound `$this`) count, an arrow + * function captures implicitly and is traversed. Dynamic access + * (`$$name`, compact(), extract(), get_defined_vars(), eval, include) + * mentions everything. + * + * @param array $names + */ + private function collectMentionedVariables(Node $node, array &$names, bool &$mentionsEverything): void + { + if ($node instanceof Expr\Variable) { + if (!is_string($node->name)) { + $mentionsEverything = true; + return; + } + $names[$node->name] = true; + return; + } + if ($node instanceof Expr\Closure) { + if (!$node->static) { + $names['this'] = true; + } + foreach ($node->uses as $use) { + if (!is_string($use->var->name)) { + $mentionsEverything = true; + continue; + } + $names[$use->var->name] = true; + } + + return; + } + if ($node instanceof Expr\Eval_ || $node instanceof Expr\Include_) { + $mentionsEverything = true; + } elseif ( + $node instanceof Expr\FuncCall + && $node->name instanceof Name + && in_array($node->name->toLowerString(), ['compact', 'extract', 'get_defined_vars'], true) + ) { + $mentionsEverything = true; + } + + foreach ($node->getSubNodeNames() as $subNodeName) { + $subNode = $node->$subNodeName; + if ($subNode instanceof Node) { + $this->collectMentionedVariables($subNode, $names, $mentionsEverything); + } elseif (is_array($subNode)) { + foreach ($subNode as $item) { + if (!$item instanceof Node) { + continue; + } + $this->collectMentionedVariables($item, $names, $mentionsEverything); + } + } + } } /** @@ -1323,6 +1652,17 @@ private function processExprNodeInternal( // the walk produced $expressionResult = $this->getNonNullabilityHelper()->applyPendingEnsure($expr, $expressionResult); $this->storeExpressionResult($storage, $expr, $expressionResult); + // Force potential producers before collecting the body's constraints. + // Type reads only construct markers; they never register sites as a side effect. + $frame = $scope->getCurrentTemplateArgumentFrame(); + if ( + $frame !== null && $frame->isObserving() + && $expr instanceof Expr\CallLike + ) { + $constraints = $this->templateArgumentObserver->collectSites($expressionResult->getType()); + $expressionResult = $expressionResult->withScope($expressionResult->getScope()->addTemplateArgumentConstraints($constraints)); + $this->storeExpressionResult($storage, $expr, $expressionResult); + } // The node's own callback fires AFTER its result is stored, with the // scope captured before processing. Rules observe the same (scope, // answer) pair as at a pre-order emission - previously a pre-order @@ -1475,18 +1815,29 @@ public function popNodeGatherer(): void */ public function replayRecording(RecordingNodeCallback $recording, callable $nodeCallback, ExpressionResultStorage $storage, MutatingScope $scope): void { + $this->replayRecordingRange($recording, 0, $recording->count(), $nodeCallback, $storage, $scope); + } + + /** + * Replays the recorded pairs [$from, $to) the way callNodeCallback() would + * have emitted them: gatherer frames observe them exactly like live ones + * (with the raw walk scope), a real callback gets the callback scope - and + * a recording callback records them again (a loop's fixpoint replay running + * inside a body's observation pass). + * + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + public function replayRecordingRange(RecordingNodeCallback $recording, int $from, int $to, callable $nodeCallback, ExpressionResultStorage $storage, MutatingScope $scope): void + { + $pairs = $recording->getPairs(); $scope->pushExpressionResultStorage($storage); try { - foreach ($recording->getPairs() as [$node, $pairScope]) { + for ($i = $from; $i < $to; $i++) { + [$node, $pairScope] = $pairs[$i]; if (!$pairScope instanceof MutatingScope) { throw new ShouldNotHappenException(); } - // gatherer frames observe replayed emissions exactly like live - // ones - with the raw walk scope - foreach ($this->nodeGatherers as $gatherer) { - $gatherer($node, $pairScope); - } - $nodeCallback($node, $pairScope->toNodeCallbackScope()); + $this->callNodeCallback($nodeCallback, $node, $pairScope, $storage); } } finally { $scope->popExpressionResultStorage(); @@ -1710,7 +2061,7 @@ private function processClosureNodeInternal( ), $closureReturnStatementsNodeScope, $storage); return new ProcessClosureResult( - $scope, + $scope->addTemplateArgumentConstraints($statementResult->getScope()->getTemplateArgumentConstraints()), $statementResult->getThrowPoints(), $statementResult->getImpurePoints(), $invalidateExpressions, @@ -1810,7 +2161,7 @@ private function processClosureNodeInternal( ), $closureReturnStatementsNodeScope, $storage); return new ProcessClosureResult( - $scope, + $scope->addTemplateArgumentConstraints($statementResult->getScope()->getTemplateArgumentConstraints()), $statementResult->getThrowPoints(), $statementResult->getImpurePoints(), $invalidateExpressions, @@ -1955,6 +2306,8 @@ public function processArrowFunctionNode( } finally { $this->popNodeGatherer(); } + $scope = $scope->addTemplateArgumentConstraints($exprResult->getScope()->getTemplateArgumentConstraints()); + $scope = $scope->addTemplateArgumentConstraints($this->collectReturnSend($arrowFunctionScope, $exprResult)); $closureTypeThrowPoints = array_map(static fn (InternalThrowPoint $throwPoint) => $throwPoint->toPublic(), $exprResult->getThrowPoints()); $closureTypeImpurePoints = array_merge($arrowFunctionImpurePoints, $exprResult->getImpurePoints()); @@ -2762,6 +3115,18 @@ public function processArgs( $gatheredArgTypeByIndex[$i] = $exprResult->getType(); $this->addGatheredArgType($gatheredTypes, $gatheredUnpack, $gatheredHasName, $originalArg, $i, $gatheredArgTypeByIndex[$i]); + $templateArgumentFrame = $this->observingTemplateArgumentFrame($scope); + if ($templateArgumentFrame !== null && $parameter !== null && $argMetadataAcceptor !== null) { + // the metadata acceptor is resolved against the arguments gathered + // before this one, so a template this argument itself decides is + // still its bound there - observe the declared parameter type, + // where such a template is uninformative and the receiver's + // class-level arguments are already in place + $scope = $scope->addTemplateArgumentConstraints($this->templateArgumentObserver->collectArgument( + $this->findOriginalParameterType($argMetadataAcceptor, $parameter) ?? $parameter->getType(), + $gatheredArgTypeByIndex[$i], + )); + } } if ($assignByReference && $lookForUnset) { @@ -3341,6 +3706,10 @@ public function processStmtVarAnnotation(MutatingScope $scope, ExpressionResultS if (!$originalType->equals($varTag->getType())) { $this->callNodeCallback($nodeCallback, new VarTagChangedExpressionTypeNode($varTag, $variableNode), $scope, $storage); } + $templateArgumentFrame = $this->observingTemplateArgumentFrame($scope); + if ($templateArgumentFrame !== null) { + $scope = $scope->addTemplateArgumentConstraints($this->templateArgumentObserver->collectSend($varTag->getType(), $originalType)); + } $nativeScope = $scope->doNotTreatPhpDocTypesAsCertain(); $scope = $scope->assignVariable( @@ -3404,14 +3773,83 @@ private function findSingleVariableLessVarTag(MutatingScope $scope, Node\Stmt $s * * @param callable(Node $node, Scope $scope): void $nodeCallback */ - public function emitVarTagChangedNode(MutatingScope $scope, ExpressionResultStorage $storage, Node\Stmt $stmt, Expr $defaultExpr, callable $nodeCallback): void + public function emitVarTagChangedNode(MutatingScope $scope, ExpressionResultStorage $storage, Node\Stmt $stmt, Expr $defaultExpr, callable $nodeCallback): TemplateArgumentConstraints { $varTag = $this->findSingleVariableLessVarTag($scope, $stmt); if ($varTag === null) { - return; + return TemplateArgumentConstraints::createEmpty(); } $this->callNodeCallback($nodeCallback, new VarTagChangedExpressionTypeNode($varTag, $defaultExpr), $scope, $storage); + $defaultExprResult = $storage->findExpressionResult($defaultExpr); + if ($defaultExprResult === null || $this->observingTemplateArgumentFrame($defaultExprResult->getScope()) === null) { + return TemplateArgumentConstraints::createEmpty(); + } + return $this->templateArgumentObserver->collectSend($varTag->getType(), $defaultExprResult->getType()); + } + + /** + * The template argument frame of the body being walked while it observes + * a body that created unresolved template arguments - null otherwise, so + * every observation hook costs a null check outside the observation pass. + */ + public function observingTemplateArgumentFrame(MutatingScope $scope): ?TemplateArgumentFrame + { + $frame = $scope->getCurrentTemplateArgumentFrame(); + if ($frame === null || !$frame->isObserving() || $scope->getTemplateArgumentConstraints() === null) { + return null; + } + + return $frame; + } + + /** + * A value leaves the function: the declared return type is a send target for + * the unresolved template arguments it carries. + */ + public function collectReturnSend(MutatingScope $scope, ExpressionResult $returnedResult): TemplateArgumentConstraints + { + $frame = $this->observingTemplateArgumentFrame($returnedResult->getScope()); + if ($frame === null) { + return TemplateArgumentConstraints::createEmpty(); + } + if ($scope->isInAnonymousFunction()) { + $declaredReturnType = $scope->getAnonymousFunctionReturnType(); + } else { + $function = $scope->getFunction(); + $declaredReturnType = $function !== null ? $function->getReturnType() : null; + } + if ($declaredReturnType === null) { + return TemplateArgumentConstraints::createEmpty(); + } + + return $this->templateArgumentObserver->collectSend($declaredReturnType, $returnedResult->getType()); + } + + /** + * A write through ArrayAccess (`$storage[$key] = $value`) reaches the + * object's template arguments exactly like the offsetSet() call it stands + * for, so observe both the key and the written value against that method's + * parameters resolved on the receiver. + */ + public function collectOffsetSetUsage(MutatingScope $scope, Type $receiverType, ?Type $keyType, Type $valueType): TemplateArgumentConstraints + { + $constraints = TemplateArgumentConstraints::createEmpty(); + $frame = $this->observingTemplateArgumentFrame($scope); + if ($frame === null || !$receiverType->hasMethod('offsetSet')->yes()) { + return $constraints; + } + + $parameters = $receiverType->getMethod('offsetSet', $scope)->getOnlyVariant()->getParameters(); + if ($keyType !== null && isset($parameters[0])) { + $constraints = $constraints->merge($this->templateArgumentObserver->collectArgument($parameters[0]->getType(), $keyType)); + } + if (!isset($parameters[1])) { + return $constraints; + } + + $constraints = $constraints->merge($this->templateArgumentObserver->collectArgument($parameters[1]->getType(), $valueType)); + return $constraints; } /** diff --git a/src/Analyser/RecordingNodeCallback.php b/src/Analyser/RecordingNodeCallback.php index 37e1b5728ed..383fe4c65b6 100644 --- a/src/Analyser/RecordingNodeCallback.php +++ b/src/Analyser/RecordingNodeCallback.php @@ -3,6 +3,7 @@ namespace PHPStan\Analyser; use PhpParser\Node; +use function count; /** * Records every (node, scope) emission of a convergence pass in order. When @@ -35,4 +36,9 @@ public function getPairs(): array return $this->pairs; } + public function count(): int + { + return count($this->pairs); + } + } diff --git a/src/Analyser/StatementListWalkState.php b/src/Analyser/StatementListWalkState.php new file mode 100644 index 00000000000..232b984545e --- /dev/null +++ b/src/Analyser/StatementListWalkState.php @@ -0,0 +1,42 @@ +scope, + $this->hasYield, + $this->alreadyTerminated, + $this->exitPoints, + $this->throwPoints, + $this->impurePoints, + ); + } + +} diff --git a/src/Analyser/StmtHandler/ExpressionHandler.php b/src/Analyser/StmtHandler/ExpressionHandler.php index 4eff0dbbe94..5697e64f47a 100644 --- a/src/Analyser/StmtHandler/ExpressionHandler.php +++ b/src/Analyser/StmtHandler/ExpressionHandler.php @@ -68,7 +68,7 @@ public function processStmt( $result = $nodeScopeResolver->processExprNode($stmt, $stmt->expr, $scope, $storage, $nodeCallback, ExpressionContext::createTopLevel()); if ($stmt->expr instanceof Expr\Throw_) { // the @var-changed-type node fires now that the thrown expression is stored - $nodeScopeResolver->emitVarTagChangedNode($preAnnotationScope, $storage, $stmt, $stmt->expr->expr, $nodeCallback); + $result = $result->withScope($result->getScope()->addTemplateArgumentConstraints($nodeScopeResolver->emitVarTagChangedNode($preAnnotationScope, $storage, $stmt, $stmt->expr->expr, $nodeCallback))); } } finally { $nodeScopeResolver->popNodeGatherer(); diff --git a/src/Analyser/StmtHandler/ReturnHandler.php b/src/Analyser/StmtHandler/ReturnHandler.php index a93bf2983bd..5b21f5227ec 100644 --- a/src/Analyser/StmtHandler/ReturnHandler.php +++ b/src/Analyser/StmtHandler/ReturnHandler.php @@ -42,10 +42,10 @@ public function processStmt( // the @var-changed-type node fires now that the expression is stored // on the scope BEFORE the @var tag re-typed the expression, so the rule // compares the tag against the expression's walked type - $nodeScopeResolver->emitVarTagChangedNode($scope, $storage, $stmt, $stmt->expr, $nodeCallback); + $varConstraints = $nodeScopeResolver->emitVarTagChangedNode($scope, $storage, $stmt, $stmt->expr, $nodeCallback); $throwPoints = $result->getThrowPoints(); $impurePoints = $result->getImpurePoints(); - $scope = $result->getScope(); + $scope = $result->getScope()->addTemplateArgumentConstraints($varConstraints)->addTemplateArgumentConstraints($nodeScopeResolver->collectReturnSend($stmtScope, $result)); $hasYield = $result->hasYield(); } else { $hasYield = false; diff --git a/src/Analyser/Traverser/GenericTypeTemplateTraverser.php b/src/Analyser/Traverser/GenericTypeTemplateTraverser.php index bebef54bc95..46481cbad8d 100644 --- a/src/Analyser/Traverser/GenericTypeTemplateTraverser.php +++ b/src/Analyser/Traverser/GenericTypeTemplateTraverser.php @@ -2,18 +2,37 @@ namespace PHPStan\Analyser\Traverser; +use PhpParser\Node\Expr; +use PHPStan\Analyser\Generics\TemplateArgumentFrame; use PHPStan\Type\ErrorType; use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\Generic\TemplateTypeHelper; use PHPStan\Type\Generic\TemplateTypeMap; +use PHPStan\Type\Generic\UnresolvedTemplateArgumentType; use PHPStan\Type\Type; use PHPStan\Type\TypeTraverserCallable; +/** + * Substitutes the class's template types in `new Foo(...)` with what the + * constructor arguments inferred for them. + * + * Without a template argument frame (feature toggle off, file top level, a walk + * started outside any body) the inferred argument is generalized as it always + * was (`new Foo(1)` is `Foo`). Under a frame the inferred argument is + * kept exact and, during the body's observation pass, wrapped in an + * UnresolvedTemplateArgumentType keyed by the site so the body's sends and + * method calls can decide it; the second pass substitutes the frame's + * resolution. An inferred argument that already carries another site's marker + * passes through - the outer result then resolves the inner site. + */ final class GenericTypeTemplateTraverser implements TypeTraverserCallable { public function __construct( private readonly TemplateTypeMap $resolvedTemplateTypeMap, + private readonly Expr $site, + private readonly ?TemplateArgumentFrame $frame, + private readonly bool $allowUnresolved, ) { } @@ -25,11 +44,27 @@ public function traverse(Type $type, callable $traverse): Type { if ($type instanceof TemplateType && !$type->isArgument()) { $newType = $this->resolvedTemplateTypeMap->getType($type->getName()); - if ($newType === null || $newType instanceof ErrorType) { - return $type->getDefault() ?? $type->getBound(); + if ($this->frame === null) { + if ($newType === null || $newType instanceof ErrorType) { + return $type->getDefault() ?? $type->getBound(); + } + + return TemplateTypeHelper::generalizeInferredTemplateType($type, $newType); + } + + $initialType = $newType === null || $newType instanceof ErrorType ? null : $newType; + // a synthetic site (the parent constructor's `new`) always hands out + // markers - the real site re-keys and resolves them + $synthetic = $this->site->getAttribute(TemplateArgumentFrame::SYNTHETIC_SITE_ATTRIBUTE) === true; + if ($synthetic || ($this->allowUnresolved && $this->frame->isObserving())) { + if ($initialType instanceof UnresolvedTemplateArgumentType) { + return $initialType; + } + + return new UnresolvedTemplateArgumentType($this->site, $type, $initialType); } - return TemplateTypeHelper::generalizeInferredTemplateType($type, $newType); + return $this->frame->resolve($this->site, $type->getName()) ?? $initialType ?? $this->frame->resolveOrUnconstrained($this->site, $type); } return $traverse($type); diff --git a/src/Reflection/ResolvedFunctionVariant.php b/src/Reflection/ResolvedFunctionVariant.php index 92675f4f197..7074b8a4b73 100644 --- a/src/Reflection/ResolvedFunctionVariant.php +++ b/src/Reflection/ResolvedFunctionVariant.php @@ -2,6 +2,8 @@ namespace PHPStan\Reflection; +use PhpParser\Node\Expr; +use PHPStan\Analyser\Generics\TemplateArgumentFrame; use PHPStan\Type\Type; interface ResolvedFunctionVariant extends ExtendedParametersAcceptor @@ -11,4 +13,13 @@ public function getOriginalParametersAcceptor(): ParametersAcceptor; public function getReturnTypeWithUnresolvableTemplateTypes(): Type; + /** + * The return type with the function's template arguments inferred from the + * arguments kept exact and, under a frame, marked as unresolved for the + * body to decide - where getReturnType() generalizes them (f(1) with + * `@return Foo` is Foo). Only the analyser's call handlers use it; + * $site is the call node the markers are keyed by. + */ + public function getReturnTypeWithUnresolvedTemplateArguments(Expr $site, TemplateArgumentFrame $frame, bool $allowUnresolved): Type; + } diff --git a/src/Reflection/ResolvedFunctionVariantWithCallable.php b/src/Reflection/ResolvedFunctionVariantWithCallable.php index 7e57be7a952..3d21f9a121f 100644 --- a/src/Reflection/ResolvedFunctionVariantWithCallable.php +++ b/src/Reflection/ResolvedFunctionVariantWithCallable.php @@ -2,6 +2,8 @@ namespace PHPStan\Reflection; +use PhpParser\Node\Expr; +use PHPStan\Analyser\Generics\TemplateArgumentFrame; use PHPStan\Node\InvalidateExprNode; use PHPStan\Reflection\Callables\CallableParametersAcceptor; use PHPStan\Reflection\Callables\SimpleImpurePoint; @@ -70,6 +72,11 @@ public function getReturnTypeWithUnresolvableTemplateTypes(): Type return $this->parametersAcceptor->getReturnTypeWithUnresolvableTemplateTypes(); } + public function getReturnTypeWithUnresolvedTemplateArguments(Expr $site, TemplateArgumentFrame $frame, bool $allowUnresolved): Type + { + return $this->parametersAcceptor->getReturnTypeWithUnresolvedTemplateArguments($site, $frame, $allowUnresolved); + } + public function getReturnType(): Type { return $this->parametersAcceptor->getReturnType(); diff --git a/src/Reflection/ResolvedFunctionVariantWithOriginal.php b/src/Reflection/ResolvedFunctionVariantWithOriginal.php index 0151110214b..20af88946fc 100644 --- a/src/Reflection/ResolvedFunctionVariantWithOriginal.php +++ b/src/Reflection/ResolvedFunctionVariantWithOriginal.php @@ -2,6 +2,8 @@ namespace PHPStan\Reflection; +use PhpParser\Node\Expr; +use PHPStan\Analyser\Generics\TemplateArgumentFrame; use PHPStan\Reflection\Php\ExtendedDummyParameter; use PHPStan\Type\ConditionalTypeForParameter; use PHPStan\Type\ErrorType; @@ -12,10 +14,12 @@ use PHPStan\Type\Generic\TemplateTypeMap; use PHPStan\Type\Generic\TemplateTypeVariance; use PHPStan\Type\Generic\TemplateTypeVarianceMap; +use PHPStan\Type\Generic\UnresolvedTemplateArgumentType; use PHPStan\Type\NonAcceptingNeverType; use PHPStan\Type\Type; use PHPStan\Type\TypeTraverser; use PHPStan\Type\TypeUtils; +use WeakReference; use function array_key_exists; use function array_map; @@ -33,6 +37,9 @@ final class ResolvedFunctionVariantWithOriginal implements ResolvedFunctionVaria private ?Type $phpDocReturnType = null; + /** @var array{Expr, WeakReference, bool, Type}|null */ + private ?array $returnTypeWithUnresolvedTemplateArguments = null; + /** * @param array $passedArgs */ @@ -176,6 +183,29 @@ public function getReturnType(): Type return $type; } + public function getReturnTypeWithUnresolvedTemplateArguments(Expr $site, TemplateArgumentFrame $frame, bool $allowUnresolved): Type + { + $cached = $this->returnTypeWithUnresolvedTemplateArguments; + if ($cached !== null && $cached[0] === $site && $cached[1]->get() === $frame && $cached[2] === $allowUnresolved) { + return $cached[3]; + } + + $type = TypeUtils::resolveLateResolvableTypes( + TemplateTypeHelper::resolveTemplateTypes( + $this->resolveConditionalTypesForParameter( + $this->resolveResolvableTemplateTypes($this->parametersAcceptor->getReturnType(), TemplateTypeVariance::createCovariant(), $site, $frame, $allowUnresolved), + ), + $this->resolvedTemplateTypeMap, + $this->callSiteVarianceMap, + TemplateTypeVariance::createCovariant(), + ), + false, + ); + $this->returnTypeWithUnresolvedTemplateArguments = [$site, WeakReference::create($frame), $allowUnresolved, $type]; + + return $type; + } + public function getPhpDocReturnType(): Type { $type = $this->phpDocReturnType; @@ -202,11 +232,11 @@ public function getNativeReturnType(): Type return $this->parametersAcceptor->getNativeReturnType(); } - private function resolveResolvableTemplateTypes(Type $type, TemplateTypeVariance $positionVariance): Type + private function resolveResolvableTemplateTypes(Type $type, TemplateTypeVariance $positionVariance, ?Expr $site = null, ?TemplateArgumentFrame $frame = null, bool $allowUnresolved = true): Type { $references = $type->getReferencedTemplateTypes($positionVariance); - $objectCb = function (Type $type, callable $traverse) use ($references): Type { + $objectCb = function (Type $type, callable $traverse) use ($references, $site, $frame, $allowUnresolved): Type { if ( $type instanceof TemplateType && !$type->isArgument() @@ -217,7 +247,11 @@ private function resolveResolvableTemplateTypes(Type $type, TemplateTypeVariance return $traverse($type); } - $newType = TemplateTypeHelper::generalizeInferredTemplateType($type, $newType); + if ($site !== null && $frame !== null) { + $newType = $this->unresolvedOrResolvedTemplateArgument($type, $newType, $site, $frame, $allowUnresolved); + } else { + $newType = TemplateTypeHelper::generalizeInferredTemplateType($type, $newType); + } $variance = TemplateTypeVariance::createInvariant(); foreach ($references as $reference) { // this uses identity to distinguish between different occurrences of the same template type @@ -268,6 +302,12 @@ private function resolveResolvableTemplateTypes(Type $type, TemplateTypeVariance } } + if ($variance->covariant()) { + // an unresolved template argument inferred from a generic argument + // and returned bare is a derived value - see TemplateTypeHelper::resolveTemplateTypes() + $newType = UnresolvedTemplateArgumentType::unwrapBare($newType); + } + $callSiteVariance = $this->callSiteVarianceMap->getVariance($type->getName()); if ($callSiteVariance === null || $callSiteVariance->invariant()) { return $newType; @@ -288,6 +328,25 @@ private function resolveResolvableTemplateTypes(Type $type, TemplateTypeVariance }); } + /** + * An inferred template argument inside a generic object of the return type: + * during the body's observation pass a marker keyed by the call (an inferred + * argument that already carries another site's marker passes through - the + * outer result then resolves the inner site), under a resolved frame its + * resolution, else the exact inferred type. + */ + private function unresolvedOrResolvedTemplateArgument(TemplateType $template, Type $inferred, Expr $site, TemplateArgumentFrame $frame, bool $allowUnresolved): Type + { + if ($allowUnresolved && $frame->isObserving()) { + if ($inferred instanceof UnresolvedTemplateArgumentType) { + return $inferred; + } + return new UnresolvedTemplateArgumentType($site, $template, $inferred); + } + + return $frame->resolve($site, $template->getName()) ?? $inferred; + } + private function resolveConditionalTypesForParameter(Type $type): Type { return TypeTraverser::map($type, function (Type $type, callable $traverse): Type { diff --git a/src/Testing/RuleTestCase.php b/src/Testing/RuleTestCase.php index 247331d03f2..ed9140b53dd 100644 --- a/src/Testing/RuleTestCase.php +++ b/src/Testing/RuleTestCase.php @@ -8,6 +8,8 @@ use PHPStan\Analyser\Error; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\FileAnalyser; +use PHPStan\Analyser\Generics\TemplateArgumentObserver; +use PHPStan\Analyser\Generics\TemplateArgumentResolver; use PHPStan\Analyser\IgnoreErrorExtension; use PHPStan\Analyser\InternalError; use PHPStan\Analyser\LocalIgnoresProcessor; @@ -87,6 +89,8 @@ protected function createNodeScopeResolver(): NodeScopeResolver return new NodeScopeResolver( self::getContainer(), + self::getContainer()->getByType(TemplateArgumentObserver::class), + self::getContainer()->getByType(TemplateArgumentResolver::class), $reflectionProvider, self::getContainer()->getExtensionsCollection(FunctionParameterOutTypeExtension::class), self::getContainer()->getExtensionsCollection(MethodParameterOutTypeExtension::class), @@ -105,6 +109,7 @@ protected function createNodeScopeResolver(): NodeScopeResolver self::getContainer()->getParameter('exceptions')['implicitThrows'], $this->shouldTreatPhpDocTypesAsCertain(), self::getContainer()->getByType(ExpressionResultFactory::class), + self::getContainer()->getParameter('featureToggles')['unresolvedTemplateArguments'], ); } diff --git a/src/Testing/TypeInferenceTestCase.php b/src/Testing/TypeInferenceTestCase.php index 51541bfa411..72d8e567694 100644 --- a/src/Testing/TypeInferenceTestCase.php +++ b/src/Testing/TypeInferenceTestCase.php @@ -7,6 +7,8 @@ use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Name; use PHPStan\Analyser\ExpressionResultFactory; +use PHPStan\Analyser\Generics\TemplateArgumentObserver; +use PHPStan\Analyser\Generics\TemplateArgumentResolver; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\PerFileAnalysisResettable; @@ -63,6 +65,8 @@ protected static function createNodeScopeResolver(): NodeScopeResolver return new NodeScopeResolver( $container, + $container->getByType(TemplateArgumentObserver::class), + $container->getByType(TemplateArgumentResolver::class), $reflectionProvider, $container->getExtensionsCollection(FunctionParameterOutTypeExtension::class), $container->getExtensionsCollection(MethodParameterOutTypeExtension::class), @@ -81,6 +85,7 @@ protected static function createNodeScopeResolver(): NodeScopeResolver $container->getParameter('exceptions')['implicitThrows'], $container->getParameter('treatPhpDocTypesAsCertain'), $container->getByType(ExpressionResultFactory::class), + $container->getParameter('featureToggles')['unresolvedTemplateArguments'], ); } diff --git a/src/Type/Generic/TemplateTypeHelper.php b/src/Type/Generic/TemplateTypeHelper.php index 5050f8f1552..157385c0c18 100644 --- a/src/Type/Generic/TemplateTypeHelper.php +++ b/src/Type/Generic/TemplateTypeHelper.php @@ -53,6 +53,12 @@ public static function resolveTemplateTypes( return $traverse($type->getDefault() ?? $type->getBound()); } + if ($variance->covariant()) { + // a bare unresolved argument read out of the object (Foo::get(): T) + // is a derived value - see UnresolvedTemplateArgumentType::unwrapBare() + $newType = UnresolvedTemplateArgumentType::unwrapBare($newType); + } + $callSiteVariance = $callSiteVariances->getVariance($type->getName()); if ($callSiteVariance === null || $callSiteVariance->invariant()) { return $newType; diff --git a/src/Type/Generic/UnresolvedTemplateArgumentType.php b/src/Type/Generic/UnresolvedTemplateArgumentType.php new file mode 100644 index 00000000000..7425a4a2a42 --- /dev/null +++ b/src/Type/Generic/UnresolvedTemplateArgumentType.php @@ -0,0 +1,851 @@ +` does not accept `Foo` and a union of + * the two keeps both members - the marker survives until it is observed. + * + * Immutable: turbo's TypeCombinatorCache hashes the object structurally over + * its properties (the site node by identity) and caches the hash per instance. + */ +final class UnresolvedTemplateArgumentType implements CompoundType +{ + + public function __construct( + private Expr $site, + private TemplateType $templateType, + private ?Type $initialType, + ) + { + if ($initialType instanceof self) { + throw new ShouldNotHappenException('The initial type of an unresolved template argument is never itself unresolved.'); + } + } + + public function getSite(): Expr + { + return $this->site; + } + + public function getTemplateName(): string + { + return $this->templateType->getName(); + } + + public function getTemplate(): TemplateType + { + return $this->templateType; + } + + public function getInitialType(): ?Type + { + return $this->initialType; + } + + /** + * The type this marker behaves as: the inferred type, or the template's + * default/bound when nothing could be inferred. + */ + public function getDelegate(): Type + { + return $this->initialType ?? $this->templateType->getDefault() ?? $this->templateType->getBound(); + } + + public function withInitialType(?Type $initialType): self + { + return new self($this->site, $this->templateType, $initialType); + } + + /** Re-keys a marker produced by a synthetic node onto the real site and its template. */ + public function withSite(Expr $site, TemplateType $templateType): self + { + return new self($site, $templateType, $this->initialType); + } + + /** + * Replaces the markers standing bare in $type - not inside an object's + * arguments - by their delegates: a value read out of the object + * (`Foo::get(): T`, or a union of such reads) is derived, it never + * constrains the site, and it must not look like the object's own argument + * to the code reading it. Objects keep their arguments: `Foo::self(): self` + * still carries the site. + */ + public static function unwrapBare(Type $type): Type + { + if ($type instanceof self) { + return self::unwrapBare($type->getDelegate()); + } + if ($type->isObject()->yes() || !$type->hasTemplateOrLateResolvableType() && !self::containsBareMarkerShallow($type)) { + return $type; + } + + return TypeTraverser::map($type, static function (Type $type, callable $traverse): Type { + if ($type instanceof self) { + return self::unwrapBare($type->getDelegate()); + } + if ($type->isObject()->yes()) { + return $type; + } + + return $traverse($type); + }); + } + + private static function containsBareMarkerShallow(Type $type): bool + { + if ($type instanceof UnionType) { + foreach ($type->getTypes() as $member) { + if ($member instanceof self) { + return true; + } + } + } + + return $type->isIterable()->yes() && !$type->isObject()->yes() + && ($type->getIterableValueType() instanceof self || $type->getIterableKeyType() instanceof self); + } + + public function equals(Type $type): bool + { + return $type instanceof self + && $type->site === $this->site + && $type->templateType->getName() === $this->templateType->getName(); + } + + public function describe(VerbosityLevel $level): string + { + if ($level->isCache()) { + return sprintf('unresolved#%d(%s)', spl_object_id($this->site), $this->getDelegate()->describe($level)); + } + + return sprintf('unresolved(%s)', $this->getDelegate()->describe($level)); + } + + public function accepts(Type $type, bool $strictTypes): AcceptsResult + { + return $this->getDelegate()->accepts($type, $strictTypes); + } + + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult + { + return $this->getDelegate()->isSuperTypeOf($type); + } + + public function isAcceptedBy(Type $acceptingType, bool $strictTypes): AcceptsResult + { + return $acceptingType->accepts($this->getDelegate(), $strictTypes); + } + + public function isSubTypeOf(Type $otherType): IsSuperTypeOfResult + { + return $otherType->isSuperTypeOf($this->getDelegate()); + } + + public function isGreaterThan(Type $otherType, PhpVersion $phpVersion): TrinaryLogic + { + return $otherType->isSmallerThan($this->getDelegate(), $phpVersion); + } + + public function isGreaterThanOrEqual(Type $otherType, PhpVersion $phpVersion): TrinaryLogic + { + return $otherType->isSmallerThanOrEqual($this->getDelegate(), $phpVersion); + } + + public function traverse(callable $cb): Type + { + if ($this->initialType === null) { + return $this; + } + + $newInitialType = $cb($this->initialType); + if ($newInitialType === $this->initialType) { + return $this; + } + + return $this->withInitialType($newInitialType); + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + if ($this->initialType === null) { + return $this; + } + + $newInitialType = $cb($this->initialType, $right); + if ($newInitialType === $this->initialType) { + return $this; + } + + return $this->withInitialType($newInitialType); + } + + public function generalize(GeneralizePrecision $precision): Type + { + if ($this->initialType === null) { + return $this; + } + + return $this->withInitialType($this->initialType->generalize($precision)); + } + + public function tryRemove(Type $typeToRemove): ?Type + { + return $this->getDelegate()->tryRemove($typeToRemove); + } + + public function toCoercedArgumentType(bool $strictTypes): Type + { + return $this->getDelegate()->toCoercedArgumentType($strictTypes); + } + + public function hasTemplateOrLateResolvableType(): bool + { + return $this->getDelegate()->hasTemplateOrLateResolvableType(); + } + + public function toPhpDocNode(): TypeNode + { + return $this->getDelegate()->toPhpDocNode(); + } + + public function getReferencedClasses(): array + { + return $this->getDelegate()->getReferencedClasses(); + } + + public function getObjectClassNames(): array + { + return $this->getDelegate()->getObjectClassNames(); + } + + public function getObjectClassReflections(): array + { + return $this->getDelegate()->getObjectClassReflections(); + } + + public function getClassStringType(): Type + { + return $this->getDelegate()->getClassStringType(); + } + + public function getClassStringObjectType(): Type + { + return $this->getDelegate()->getClassStringObjectType(); + } + + public function getObjectTypeOrClassStringObjectType(): Type + { + return $this->getDelegate()->getObjectTypeOrClassStringObjectType(); + } + + public function isObject(): TrinaryLogic + { + return $this->getDelegate()->isObject(); + } + + public function isEnum(): TrinaryLogic + { + return $this->getDelegate()->isEnum(); + } + + public function getArrays(): array + { + return $this->getDelegate()->getArrays(); + } + + public function getConstantArrays(): array + { + return $this->getDelegate()->getConstantArrays(); + } + + public function getConstantStrings(): array + { + return $this->getDelegate()->getConstantStrings(); + } + + public function canAccessProperties(): TrinaryLogic + { + return $this->getDelegate()->canAccessProperties(); + } + + public function hasProperty(string $propertyName): TrinaryLogic + { + return $this->getDelegate()->hasProperty($propertyName); + } + + public function getProperty(string $propertyName, ClassMemberAccessAnswerer $scope): ExtendedPropertyReflection + { + return $this->getDelegate()->getProperty($propertyName, $scope); + } + + public function getUnresolvedPropertyPrototype(string $propertyName, ClassMemberAccessAnswerer $scope): UnresolvedPropertyPrototypeReflection + { + return $this->getDelegate()->getUnresolvedPropertyPrototype($propertyName, $scope); + } + + public function hasInstanceProperty(string $propertyName): TrinaryLogic + { + return $this->getDelegate()->hasInstanceProperty($propertyName); + } + + public function getInstanceProperty(string $propertyName, ClassMemberAccessAnswerer $scope): ExtendedPropertyReflection + { + return $this->getDelegate()->getInstanceProperty($propertyName, $scope); + } + + public function getUnresolvedInstancePropertyPrototype(string $propertyName, ClassMemberAccessAnswerer $scope): UnresolvedPropertyPrototypeReflection + { + return $this->getDelegate()->getUnresolvedInstancePropertyPrototype($propertyName, $scope); + } + + public function hasStaticProperty(string $propertyName): TrinaryLogic + { + return $this->getDelegate()->hasStaticProperty($propertyName); + } + + public function getStaticProperty(string $propertyName, ClassMemberAccessAnswerer $scope): ExtendedPropertyReflection + { + return $this->getDelegate()->getStaticProperty($propertyName, $scope); + } + + public function getUnresolvedStaticPropertyPrototype(string $propertyName, ClassMemberAccessAnswerer $scope): UnresolvedPropertyPrototypeReflection + { + return $this->getDelegate()->getUnresolvedStaticPropertyPrototype($propertyName, $scope); + } + + public function canCallMethods(): TrinaryLogic + { + return $this->getDelegate()->canCallMethods(); + } + + public function hasMethod(string $methodName): TrinaryLogic + { + return $this->getDelegate()->hasMethod($methodName); + } + + public function getMethod(string $methodName, ClassMemberAccessAnswerer $scope): ExtendedMethodReflection + { + return $this->getDelegate()->getMethod($methodName, $scope); + } + + public function getUnresolvedMethodPrototype(string $methodName, ClassMemberAccessAnswerer $scope): UnresolvedMethodPrototypeReflection + { + return $this->getDelegate()->getUnresolvedMethodPrototype($methodName, $scope); + } + + public function canAccessConstants(): TrinaryLogic + { + return $this->getDelegate()->canAccessConstants(); + } + + public function hasConstant(string $constantName): TrinaryLogic + { + return $this->getDelegate()->hasConstant($constantName); + } + + public function getConstant(string $constantName): ClassConstantReflection + { + return $this->getDelegate()->getConstant($constantName); + } + + public function isIterable(): TrinaryLogic + { + return $this->getDelegate()->isIterable(); + } + + public function isIterableAtLeastOnce(): TrinaryLogic + { + return $this->getDelegate()->isIterableAtLeastOnce(); + } + + public function getArraySize(): Type + { + return $this->getDelegate()->getArraySize(); + } + + public function getIterableKeyType(): Type + { + return $this->getDelegate()->getIterableKeyType(); + } + + public function getFirstIterableKeyType(): Type + { + return $this->getDelegate()->getFirstIterableKeyType(); + } + + public function getLastIterableKeyType(): Type + { + return $this->getDelegate()->getLastIterableKeyType(); + } + + public function getIterableValueType(): Type + { + return $this->getDelegate()->getIterableValueType(); + } + + public function getFirstIterableValueType(): Type + { + return $this->getDelegate()->getFirstIterableValueType(); + } + + public function getLastIterableValueType(): Type + { + return $this->getDelegate()->getLastIterableValueType(); + } + + public function isArray(): TrinaryLogic + { + return $this->getDelegate()->isArray(); + } + + public function isConstantArray(): TrinaryLogic + { + return $this->getDelegate()->isConstantArray(); + } + + public function isOversizedArray(): TrinaryLogic + { + return $this->getDelegate()->isOversizedArray(); + } + + public function isList(): TrinaryLogic + { + return $this->getDelegate()->isList(); + } + + public function isOffsetAccessible(): TrinaryLogic + { + return $this->getDelegate()->isOffsetAccessible(); + } + + public function isOffsetAccessLegal(): TrinaryLogic + { + return $this->getDelegate()->isOffsetAccessLegal(); + } + + public function hasOffsetValueType(Type $offsetType): TrinaryLogic + { + return $this->getDelegate()->hasOffsetValueType($offsetType); + } + + public function getOffsetValueType(Type $offsetType): Type + { + return $this->getDelegate()->getOffsetValueType($offsetType); + } + + public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $unionValues = true): Type + { + return $this->getDelegate()->setOffsetValueType($offsetType, $valueType, $unionValues); + } + + public function setExistingOffsetValueType(Type $offsetType, Type $valueType): Type + { + return $this->getDelegate()->setExistingOffsetValueType($offsetType, $valueType); + } + + public function unsetOffset(Type $offsetType): Type + { + return $this->getDelegate()->unsetOffset($offsetType); + } + + public function getKeysArrayFiltered(Type $filterValueType, TrinaryLogic $strict): Type + { + return $this->getDelegate()->getKeysArrayFiltered($filterValueType, $strict); + } + + public function getKeysArray(): Type + { + return $this->getDelegate()->getKeysArray(); + } + + public function getValuesArray(): Type + { + return $this->getDelegate()->getValuesArray(); + } + + public function chunkArray(Type $lengthType, TrinaryLogic $preserveKeys): Type + { + return $this->getDelegate()->chunkArray($lengthType, $preserveKeys); + } + + public function fillKeysArray(Type $valueType): Type + { + return $this->getDelegate()->fillKeysArray($valueType); + } + + public function flipArray(): Type + { + return $this->getDelegate()->flipArray(); + } + + public function intersectKeyArray(Type $otherArraysType): Type + { + return $this->getDelegate()->intersectKeyArray($otherArraysType); + } + + public function popArray(): Type + { + return $this->getDelegate()->popArray(); + } + + public function reverseArray(TrinaryLogic $preserveKeys): Type + { + return $this->getDelegate()->reverseArray($preserveKeys); + } + + public function searchArray(Type $needleType, ?TrinaryLogic $strict = null): Type + { + return $this->getDelegate()->searchArray($needleType, $strict); + } + + public function shiftArray(): Type + { + return $this->getDelegate()->shiftArray(); + } + + public function shuffleArray(): Type + { + return $this->getDelegate()->shuffleArray(); + } + + public function sliceArray(Type $offsetType, Type $lengthType, TrinaryLogic $preserveKeys): Type + { + return $this->getDelegate()->sliceArray($offsetType, $lengthType, $preserveKeys); + } + + public function spliceArray(Type $offsetType, Type $lengthType, Type $replacementType): Type + { + return $this->getDelegate()->spliceArray($offsetType, $lengthType, $replacementType); + } + + public function truncateListToSize(Type $sizeType): Type + { + return $this->getDelegate()->truncateListToSize($sizeType); + } + + public function makeListMaybe(): Type + { + return $this->getDelegate()->makeListMaybe(); + } + + public function mapValueType(callable $cb): Type + { + return $this->getDelegate()->mapValueType($cb); + } + + public function mapKeyType(callable $cb): Type + { + return $this->getDelegate()->mapKeyType($cb); + } + + public function makeAllArrayKeysOptional(): Type + { + return $this->getDelegate()->makeAllArrayKeysOptional(); + } + + public function changeKeyCaseArray(?int $case): Type + { + return $this->getDelegate()->changeKeyCaseArray($case); + } + + public function filterArrayRemovingFalsey(): Type + { + return $this->getDelegate()->filterArrayRemovingFalsey(); + } + + public function getEnumCases(): array + { + return $this->getDelegate()->getEnumCases(); + } + + public function getEnumCaseObject(): ?EnumCaseObjectType + { + return $this->getDelegate()->getEnumCaseObject(); + } + + public function getFiniteTypes(): array + { + return $this->getDelegate()->getFiniteTypes(); + } + + public function exponentiate(Type $exponent): Type + { + return $this->getDelegate()->exponentiate($exponent); + } + + public function isCallable(): TrinaryLogic + { + return $this->getDelegate()->isCallable(); + } + + public function getCallableParametersAcceptors(ClassMemberAccessAnswerer $scope): array + { + return $this->getDelegate()->getCallableParametersAcceptors($scope); + } + + public function isCloneable(): TrinaryLogic + { + return $this->getDelegate()->isCloneable(); + } + + public function toBoolean(): BooleanType + { + return $this->getDelegate()->toBoolean(); + } + + public function toNumber(): Type + { + return $this->getDelegate()->toNumber(); + } + + public function toBitwiseNotType(): Type + { + return $this->getDelegate()->toBitwiseNotType(); + } + + public function toGetClassResultType(): Type + { + return $this->getDelegate()->toGetClassResultType(); + } + + public function toClassConstantType(ReflectionProvider $reflectionProvider): Type + { + return $this->getDelegate()->toClassConstantType($reflectionProvider); + } + + public function toObjectTypeForInstanceofCheck(): ClassNameToObjectTypeResult + { + return $this->getDelegate()->toObjectTypeForInstanceofCheck(); + } + + public function toObjectTypeForIsACheck(Type $objectOrClassType, bool $allowString, bool $allowSameClass): ClassNameToObjectTypeResult + { + return $this->getDelegate()->toObjectTypeForIsACheck($objectOrClassType, $allowString, $allowSameClass); + } + + public function toInteger(): Type + { + return $this->getDelegate()->toInteger(); + } + + public function toFloat(): Type + { + return $this->getDelegate()->toFloat(); + } + + public function toString(): Type + { + return $this->getDelegate()->toString(); + } + + public function toArray(): Type + { + return $this->getDelegate()->toArray(); + } + + public function toArrayKey(): Type + { + return $this->getDelegate()->toArrayKey(); + } + + public function isSmallerThan(Type $otherType, PhpVersion $phpVersion): TrinaryLogic + { + return $this->getDelegate()->isSmallerThan($otherType, $phpVersion); + } + + public function isSmallerThanOrEqual(Type $otherType, PhpVersion $phpVersion): TrinaryLogic + { + return $this->getDelegate()->isSmallerThanOrEqual($otherType, $phpVersion); + } + + public function isConstantValue(): TrinaryLogic + { + return $this->getDelegate()->isConstantValue(); + } + + public function isConstantScalarValue(): TrinaryLogic + { + return $this->getDelegate()->isConstantScalarValue(); + } + + public function getConstantScalarTypes(): array + { + return $this->getDelegate()->getConstantScalarTypes(); + } + + public function getConstantScalarValues(): array + { + return $this->getDelegate()->getConstantScalarValues(); + } + + public function isNull(): TrinaryLogic + { + return $this->getDelegate()->isNull(); + } + + public function isTrue(): TrinaryLogic + { + return $this->getDelegate()->isTrue(); + } + + public function isFalse(): TrinaryLogic + { + return $this->getDelegate()->isFalse(); + } + + public function isBoolean(): TrinaryLogic + { + return $this->getDelegate()->isBoolean(); + } + + public function isFloat(): TrinaryLogic + { + return $this->getDelegate()->isFloat(); + } + + public function isInteger(): TrinaryLogic + { + return $this->getDelegate()->isInteger(); + } + + public function isString(): TrinaryLogic + { + return $this->getDelegate()->isString(); + } + + public function isNumericString(): TrinaryLogic + { + return $this->getDelegate()->isNumericString(); + } + + public function isDecimalIntegerString(): TrinaryLogic + { + return $this->getDelegate()->isDecimalIntegerString(); + } + + public function isNonEmptyString(): TrinaryLogic + { + return $this->getDelegate()->isNonEmptyString(); + } + + public function isNonFalsyString(): TrinaryLogic + { + return $this->getDelegate()->isNonFalsyString(); + } + + public function isLiteralString(): TrinaryLogic + { + return $this->getDelegate()->isLiteralString(); + } + + public function isLowercaseString(): TrinaryLogic + { + return $this->getDelegate()->isLowercaseString(); + } + + public function isUppercaseString(): TrinaryLogic + { + return $this->getDelegate()->isUppercaseString(); + } + + public function isClassString(): TrinaryLogic + { + return $this->getDelegate()->isClassString(); + } + + public function isVoid(): TrinaryLogic + { + return $this->getDelegate()->isVoid(); + } + + public function isScalar(): TrinaryLogic + { + return $this->getDelegate()->isScalar(); + } + + public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType + { + return $this->getDelegate()->looseCompare($type, $phpVersion); + } + + public function getSmallerType(PhpVersion $phpVersion): Type + { + return $this->getDelegate()->getSmallerType($phpVersion); + } + + public function getSmallerOrEqualType(PhpVersion $phpVersion): Type + { + return $this->getDelegate()->getSmallerOrEqualType($phpVersion); + } + + public function getGreaterType(PhpVersion $phpVersion): Type + { + return $this->getDelegate()->getGreaterType($phpVersion); + } + + public function getGreaterOrEqualType(PhpVersion $phpVersion): Type + { + return $this->getDelegate()->getGreaterOrEqualType($phpVersion); + } + + public function getTemplateType(string $ancestorClassName, string $templateTypeName): Type + { + return $this->getDelegate()->getTemplateType($ancestorClassName, $templateTypeName); + } + + public function inferTemplateTypes(Type $receivedType): TemplateTypeMap + { + return $this->getDelegate()->inferTemplateTypes($receivedType); + } + + public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array + { + return $this->getDelegate()->getReferencedTemplateTypes($positionVariance); + } + + public function toAbsoluteNumber(): Type + { + return $this->getDelegate()->toAbsoluteNumber(); + } + +} diff --git a/src/Type/ObjectType.php b/src/Type/ObjectType.php index a6487f71bf9..d4fcbfd4d29 100644 --- a/src/Type/ObjectType.php +++ b/src/Type/ObjectType.php @@ -46,6 +46,7 @@ use PHPStan\Type\Generic\GenericClassStringType; use PHPStan\Type\Generic\GenericObjectType; use PHPStan\Type\Generic\TemplateTypeHelper; +use PHPStan\Type\Generic\UnresolvedTemplateArgumentType; use PHPStan\Type\Traits\MaybeIterableTypeTrait; use PHPStan\Type\Traits\NonArrayTypeTrait; use PHPStan\Type\Traits\NonGeneralizableTypeTrait; @@ -1203,6 +1204,10 @@ public function getTemplateType(string $ancestorClassName, string $templateTypeN if ($type === null) { return new ErrorType(); } + if ($type instanceof UnresolvedTemplateArgumentType) { + // read out of the object as a derived value - see TemplateTypeHelper::resolveTemplateTypes() + return $type->getDelegate(); + } if ($type instanceof ErrorType) { $templateTypeMap = $ancestorClassReflection->getTemplateTypeMap(); $templateType = $templateTypeMap->getType($templateTypeName); diff --git a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php index 788b3ded0d6..4b70f9fa7cd 100644 --- a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php +++ b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php @@ -763,8 +763,9 @@ public function testBug7068(): void public function testDiscussion6993(): void { $errors = $this->runAnalyse(__DIR__ . '/nsrt/bug-6993.php'); - $this->assertCount(1, $errors); - $this->assertSame('Parameter #1 $specificable of method Bug6993\AndSpecificationValidator::isSatisfiedBy() expects Bug6993\Foo, Bug6993\Bar given.', $errors[0]->getMessage()); + // the calls with Foo and Bar are lower bounds on TValue, which the + // constructor argument does not pin (unresolvedTemplateArguments) + $this->assertNoErrors($errors); } public function testBug7077(): void diff --git a/tests/PHPStan/Analyser/AnalyserTest.php b/tests/PHPStan/Analyser/AnalyserTest.php index 933d9a4c289..15856628008 100644 --- a/tests/PHPStan/Analyser/AnalyserTest.php +++ b/tests/PHPStan/Analyser/AnalyserTest.php @@ -6,6 +6,8 @@ use PhpParser\NodeVisitor; use PhpParser\NodeVisitor\NameResolver; use PhpParser\Parser\Php7; +use PHPStan\Analyser\Generics\TemplateArgumentObserver; +use PHPStan\Analyser\Generics\TemplateArgumentResolver; use PHPStan\Analyser\Ignore\IgnoredErrorHelper; use PHPStan\Analyser\Ignore\IgnoreLexer; use PHPStan\Collectors\Registry as CollectorRegistry; @@ -859,6 +861,8 @@ private function createAnalyser(): Analyser $nodeScopeResolver = new NodeScopeResolver( $container, + $container->getByType(TemplateArgumentObserver::class), + $container->getByType(TemplateArgumentResolver::class), $reflectionProvider, $container->getExtensionsCollection(FunctionParameterOutTypeExtension::class), $container->getExtensionsCollection(MethodParameterOutTypeExtension::class), @@ -877,6 +881,7 @@ private function createAnalyser(): Analyser true, $this->shouldTreatPhpDocTypesAsCertain(), $container->getByType(ExpressionResultFactory::class), + $container->getParameter('featureToggles')['unresolvedTemplateArguments'], ); $lexer = new Lexer(); $fileAnalyser = new FileAnalyser( diff --git a/tests/PHPStan/Analyser/Generics/MinimalReWalkTest.php b/tests/PHPStan/Analyser/Generics/MinimalReWalkTest.php new file mode 100644 index 00000000000..ac9a43c79d4 --- /dev/null +++ b/tests/PHPStan/Analyser/Generics/MinimalReWalkTest.php @@ -0,0 +1,43 @@ +assertFileAsserts(...$args); + } + $counters = TemplateArgumentStats::getCounters(); + } finally { + TemplateArgumentStats::$enabled = false; + } + + $this->assertSame(1, $counters['bodiesWithSites']); + // the `new`, the property send, and the assertType() reading the variable + $this->assertSame(3, $counters['statementsReWalked']); + // the ten statements never mentioning $c + $this->assertSame(10, $counters['statementsReplayed']); + } + + public static function getAdditionalConfigFiles(): array + { + return array_merge( + parent::getAdditionalConfigFiles(), + [__DIR__ . '/../../../../conf/bleedingEdge.neon'], + ); + } + +} diff --git a/tests/PHPStan/Analyser/Generics/TemplateArgumentFlowTest.php b/tests/PHPStan/Analyser/Generics/TemplateArgumentFlowTest.php new file mode 100644 index 00000000000..829f15d541d --- /dev/null +++ b/tests/PHPStan/Analyser/Generics/TemplateArgumentFlowTest.php @@ -0,0 +1,29 @@ + */ + public static function dataConstraintsSurviveControlFlow(): iterable + { + yield from self::gatherAssertTypes(__DIR__ . '/data/constraint-flow.php'); + } + + #[DataProvider('dataConstraintsSurviveControlFlow')] + public function testConstraintsSurviveControlFlow(string $assertType, string $file, mixed ...$args): void + { + $this->assertFileAsserts($assertType, $file, ...$args); + } + + public static function getAdditionalConfigFiles(): array + { + return array_merge(parent::getAdditionalConfigFiles(), [__DIR__ . '/../../../../conf/bleedingEdge.neon']); + } + +} diff --git a/tests/PHPStan/Analyser/Generics/TemplateArgumentResolverTest.php b/tests/PHPStan/Analyser/Generics/TemplateArgumentResolverTest.php new file mode 100644 index 00000000000..abd11020266 --- /dev/null +++ b/tests/PHPStan/Analyser/Generics/TemplateArgumentResolverTest.php @@ -0,0 +1,293 @@ +withSite($marker); + + return [$constraints, $site, new GenericObjectType(A\A::class, [$marker])]; + } + + private static function describe(?Type $type): ?string + { + return $type !== null ? $type->describe(VerbosityLevel::precise()) : null; + } + + public function testInvariantSendResolvesToTheFirstAcceptingSend(): void + { + [$constraints, $site, $ofMarker] = self::constraintsWithA(new ConstantIntegerType(1)); + $constraints = $constraints->merge((new TemplateArgumentObserver())->collectSend(new GenericObjectType(A\A::class, [new StringType()]), $ofMarker)); + $constraints = $constraints->merge((new TemplateArgumentObserver())->collectSend(new GenericObjectType(A\A::class, [new IntegerType()]), $ofMarker)); + $constraints = $constraints->merge((new TemplateArgumentObserver())->collectSend(new GenericObjectType(A\A::class, [TypeCombinator::union(new IntegerType(), new StringType())]), $ofMarker)); + $frame = (new TemplateArgumentResolver())->resolve($constraints, null, []); + + $this->assertSame('int', self::describe($frame->resolve($site, 'T')), 'string does not accept 1, int is the first accepting send, int|string never widens it'); + $this->assertNull($frame->resolve($site, 'U')); + $this->assertNull($frame->resolve(new Variable('other'), 'T')); + } + + public function testNoAcceptingSendKeepsTheInitialType(): void + { + [$constraints, $site, $ofMarker] = self::constraintsWithA(new ConstantIntegerType(1)); + $constraints = $constraints->merge((new TemplateArgumentObserver())->collectSend(new GenericObjectType(A\A::class, [new StringType()]), $ofMarker)); + $frame = (new TemplateArgumentResolver())->resolve($constraints, null, []); + + $this->assertSame('1', self::describe($frame->resolve($site, 'T'))); + } + + public function testNothingInferredResolvesToNeverOrToTheSend(): void + { + [$constraints, $site] = self::constraintsWithA(null); + $frame = (new TemplateArgumentResolver())->resolve($constraints, null, []); + $this->assertSame('*NEVER*', self::describe($frame->resolve($site, 'T'))); + + [$constraints, $site, $ofMarker] = self::constraintsWithA(null); + $constraints = $constraints->merge((new TemplateArgumentObserver())->collectSend(new GenericObjectType(A\A::class, [new StringType()]), $ofMarker)); + $frame = (new TemplateArgumentResolver())->resolve($constraints, null, []); + $this->assertSame('string', self::describe($frame->resolve($site, 'T')), 'nothing inferred is accepted by every send'); + } + + public function testMixedAndTemplateTargetsAreNotSends(): void + { + [$constraints, $site, $ofMarker] = self::constraintsWithA(new ConstantIntegerType(1)); + $constraints = $constraints->merge((new TemplateArgumentObserver())->collectSend(new GenericObjectType(A\A::class, [new MixedType()]), $ofMarker)); + $constraints = $constraints->merge((new TemplateArgumentObserver())->collectSend(new GenericObjectType(A\A::class, [self::template('Other', 'X')]), $ofMarker)); + $constraints = $constraints->merge((new TemplateArgumentObserver())->collectSend(self::template('Other', 'X'), $ofMarker)); + $frame = (new TemplateArgumentResolver())->resolve($constraints, null, []); + + $this->assertSame('1', self::describe($frame->resolve($site, 'T'))); + } + + public function testLowerBoundsUnionWithTheInitialUnlessASendWins(): void + { + [$constraints, $site, $ofMarker] = self::constraintsWithA(new ConstantIntegerType(1)); + $marker = $ofMarker->getTypes()[0]; + $constraints = $constraints->merge((new TemplateArgumentObserver())->collectArgument($marker, new ConstantIntegerType(2))); + $constraints = $constraints->merge((new TemplateArgumentObserver())->collectArgument($marker, new ConstantStringType('a'))); + $constraints = $constraints->merge((new TemplateArgumentObserver())->collectArgument(new ArrayType(new IntegerType(), $marker), new ArrayType(new IntegerType(), new NullType()))); + $constraints = $constraints->merge((new TemplateArgumentObserver())->collectArgument(new CallableType([], $marker, false), new CallableType([], new StringType(), false))); + $frame = (new TemplateArgumentResolver())->resolve($constraints, null, []); + + $this->assertSame("1|2|'a'|null", self::describe($frame->resolve($site, 'T')), 'callable parameters are contravariant and contribute nothing'); + + [$constraints, $site, $ofMarker] = self::constraintsWithA(new ConstantIntegerType(1)); + $constraints = $constraints->merge((new TemplateArgumentObserver())->collectArgument($ofMarker->getTypes()[0], new ConstantStringType('a'))); + $constraints = $constraints->merge((new TemplateArgumentObserver())->collectSend(new GenericObjectType(A\A::class, [new IntegerType()]), $ofMarker)); + $frame = (new TemplateArgumentResolver())->resolve($constraints, null, []); + + $this->assertSame('int', self::describe($frame->resolve($site, 'T')), 'the send wins; the second pass reports the incompatible lower bound at the call'); + } + + public function testVariance(): void + { + // call-site covariant target, known initial: not clamped + [$constraints, $site, $ofMarker] = self::constraintsWithA(new ConstantIntegerType(1)); + $constraints = $constraints->merge((new TemplateArgumentObserver())->collectSend(new GenericObjectType(A\A::class, [new IntegerType()], variances: [TemplateTypeVariance::createCovariant()]), $ofMarker)); + $frame = (new TemplateArgumentResolver())->resolve($constraints, null, []); + $this->assertSame('1', self::describe($frame->resolve($site, 'T'))); + + // call-site covariant target, nothing inferred: the upper bound is the best information + [$constraints, $site, $ofMarker] = self::constraintsWithA(null); + $constraints = $constraints->merge((new TemplateArgumentObserver())->collectSend(new GenericObjectType(A\A::class, [new IntegerType()], variances: [TemplateTypeVariance::createCovariant()]), $ofMarker)); + $frame = (new TemplateArgumentResolver())->resolve($constraints, null, []); + $this->assertSame('int', self::describe($frame->resolve($site, 'T'))); + + // call-site contravariant target: a lower bound + [$constraints, $site, $ofMarker] = self::constraintsWithA(new ConstantIntegerType(1)); + $constraints = $constraints->merge((new TemplateArgumentObserver())->collectSend(new GenericObjectType(A\A::class, [new IntegerType()], variances: [TemplateTypeVariance::createContravariant()]), $ofMarker)); + $frame = (new TemplateArgumentResolver())->resolve($constraints, null, []); + $this->assertSame('int', self::describe($frame->resolve($site, 'T'))); + + // bivariant target: nothing + [$constraints, $site, $ofMarker] = self::constraintsWithA(new ConstantIntegerType(1)); + $constraints = $constraints->merge((new TemplateArgumentObserver())->collectSend(new GenericObjectType(A\A::class, [new IntegerType()], variances: [TemplateTypeVariance::createBivariant()]), $ofMarker)); + $frame = (new TemplateArgumentResolver())->resolve($constraints, null, []); + $this->assertSame('1', self::describe($frame->resolve($site, 'T'))); + + // @template-covariant class: the declared variance is the effective one + $constraints = TemplateArgumentConstraints::createEmpty(); + $site = new Variable('site'); + $covariantMarker = new UnresolvedTemplateArgumentType($site, self::template(C\Covariant::class, 'T', TemplateTypeVariance::createCovariant()), new ConstantIntegerType(1)); + $constraints = $constraints->withSite($covariantMarker); + $constraints = $constraints->merge((new TemplateArgumentObserver())->collectSend(new GenericObjectType(C\Covariant::class, [new IntegerType()]), new GenericObjectType(C\Covariant::class, [$covariantMarker]))); + $frame = (new TemplateArgumentResolver())->resolve($constraints, null, []); + $this->assertSame('1', self::describe($frame->resolve($site, 'T'))); + + $constraints = TemplateArgumentConstraints::createEmpty(); + $unresolvableCovariantMarker = new UnresolvedTemplateArgumentType($site, self::template(C\Covariant::class, 'T', TemplateTypeVariance::createCovariant()), null); + $constraints = $constraints->withSite($unresolvableCovariantMarker); + $constraints = $constraints->merge((new TemplateArgumentObserver())->collectSend(new GenericObjectType(C\Covariant::class, [new IntegerType()]), new GenericObjectType(C\Covariant::class, [$unresolvableCovariantMarker]))); + $frame = (new TemplateArgumentResolver())->resolve($constraints, null, []); + $this->assertSame('int', self::describe($frame->resolve($site, 'T'))); + } + + public function testSendThroughAncestorAndUnionsAndNestedSites(): void + { + // SubA extends A: the send to A reaches U through @extends + $constraints = TemplateArgumentConstraints::createEmpty(); + $site = new Variable('site'); + $marker = new UnresolvedTemplateArgumentType($site, self::template(A\SubA::class, 'U'), new ConstantIntegerType(1)); + $constraints = $constraints->withSite($marker); + $constraints = $constraints->merge((new TemplateArgumentObserver())->collectSend(TypeCombinator::union(new GenericObjectType(A\A::class, [new IntegerType()]), new NullType()), TypeCombinator::union(new GenericObjectType(A\SubA::class, [$marker]), new NullType()))); + $frame = (new TemplateArgumentResolver())->resolve($constraints, null, []); + $this->assertSame('int', self::describe($frame->resolve($site, 'U'))); + + // wrap(new Foo(1)): the outer site's inferred argument carries the inner site + $constraints = TemplateArgumentConstraints::createEmpty(); + $innerSite = new Variable('inner'); + $outerSite = new Variable('outer'); + $inner = self::markerOfA($innerSite, new ConstantIntegerType(1)); + $outer = self::markerOfA($outerSite, new GenericObjectType(A\A::class, [$inner])); + $constraints = $constraints->withSite($inner); + $constraints = $constraints->withSite($outer); + $constraints = $constraints->merge((new TemplateArgumentObserver())->collectSend( + new GenericObjectType(A\A::class, [new GenericObjectType(A\A::class, [new IntegerType()])]), + new GenericObjectType(A\A::class, [$outer]), + )); + $frame = (new TemplateArgumentResolver())->resolve($constraints, null, []); + $this->assertSame('PHPStan\Type\Test\A\A', self::describe($frame->resolve($outerSite, 'T'))); + $this->assertSame('int', self::describe($frame->resolve($innerSite, 'T'))); + + // array element sends + [$constraints, $site, $ofMarker] = self::constraintsWithA(new ConstantIntegerType(1)); + $constraints = $constraints->merge((new TemplateArgumentObserver())->collectSend(new ArrayType(new IntegerType(), new GenericObjectType(A\A::class, [new IntegerType()])), new ArrayType(new IntegerType(), $ofMarker))); + $frame = (new TemplateArgumentResolver())->resolve($constraints, null, []); + $this->assertSame('int', self::describe($frame->resolve($site, 'T'))); + } + + public function testInitialTypesUnionAcrossReproducedMarkers(): void + { + $constraints = TemplateArgumentConstraints::createEmpty(); + $site = new Variable('site'); + $constraints = $constraints->withSite(self::markerOfA($site, new ConstantIntegerType(1))); + $constraints = $constraints->withSite(self::markerOfA($site, new ConstantIntegerType(2))); + $frame = (new TemplateArgumentResolver())->resolve($constraints, null, []); + + $this->assertSame('1|2', self::describe($frame->resolve($site, 'T'))); + } + + public function testContextsAndConstraintsRemainUnchangedByResolution(): void + { + [$constraints, $site, $ofMarker] = self::constraintsWithA(new ConstantIntegerType(1)); + $collecting = new TemplateArgumentFrame(null); + $branch = $constraints->merge((new TemplateArgumentObserver())->collectSend(new GenericObjectType(A\A::class, [new IntegerType()]), $ofMarker)); + $resolver = new TemplateArgumentResolver(); + $parent = $resolver->resolve($branch, null, []); + + $this->assertTrue($collecting->isObserving()); + $this->assertNull($collecting->resolve($site, 'T')); + $this->assertSame('', $collecting->getResolutionCacheKeySuffix()); + $this->assertSame('1', self::describe($resolver->resolve($constraints, null, [])->resolve($site, 'T'))); + $this->assertSame('int', self::describe($parent->resolve($site, 'T'))); + $this->assertFalse($parent->isObserving()); + $this->assertNotSame('', $parent->getResolutionCacheKeySuffix()); + + $child = new TemplateArgumentFrame($parent); + $this->assertTrue($child->isObserving()); + $this->assertSame('int', self::describe($child->resolve($site, 'T'))); + $this->assertSame($parent->getResolutionCacheKeySuffix(), $child->getResolutionCacheKeySuffix()); + $this->assertNull($child->firstSiteStatementIndex()); + } + + public function testReturnTypeCacheDistinguishesImmutableResolutions(): void + { + $template = TemplateTypeFactory::create(TemplateTypeScope::createWithFunction('wrap'), 'T', new MixedType(), TemplateTypeVariance::createInvariant()); + $type = new GenericObjectType(A\A::class, [$template]); + $variant = new ResolvedFunctionVariantWithOriginal( + new ExtendedFunctionVariant(new TemplateTypeMap(['T' => $template]), null, [], false, $type, $type, new MixedType()), + new TemplateTypeMap(['T' => new ConstantIntegerType(1)]), + TemplateTypeVarianceMap::createEmpty(), + [], + ); + $site = new Variable('site'); + $marker = new UnresolvedTemplateArgumentType($site, $template, new ConstantIntegerType(1)); + $constraints = TemplateArgumentConstraints::createEmpty()->withSite($marker); + $resolver = new TemplateArgumentResolver(); + $initial = $resolver->resolve($constraints, null, []); + $sent = $resolver->resolve($constraints->withSend($marker, new IntegerType(), TemplateTypeVariance::createInvariant()), null, []); + $collecting = new TemplateArgumentFrame(null); + $unresolved = $variant->getReturnTypeWithUnresolvedTemplateArguments($site, $collecting, true); + + $this->assertSame('PHPStan\Type\Test\A\A<1>', self::describe($variant->getReturnTypeWithUnresolvedTemplateArguments($site, $initial, true))); + $this->assertSame('PHPStan\Type\Test\A\A', self::describe($variant->getReturnTypeWithUnresolvedTemplateArguments($site, $sent, true))); + $this->assertSame('PHPStan\Type\Test\A\A<1>', self::describe($variant->getReturnTypeWithUnresolvedTemplateArguments($site, $initial, true))); + $this->assertTrue($unresolved->equals($variant->getReturnTypeWithUnresolvedTemplateArguments($site, $collecting, true))); + $this->assertTrue($collecting->isObserving()); + } + + public function testSiteAttributionByTokenPosition(): void + { + $constraints = TemplateArgumentConstraints::createEmpty(); + $inSecond = new Variable('a', ['startTokenPos' => 15]); + $inThird = new Variable('b', ['startTokenPos' => 20]); + $constraints = $constraints->withSite(self::markerOfA($inSecond, null)); + $resolver = new TemplateArgumentResolver(); + $frame = $resolver->resolve($constraints, null, [0, 10, 20]); + $this->assertSame(1, $frame->firstSiteStatementIndex()); + $this->assertTrue($frame->ownsSiteInStatement(1)); + $this->assertFalse($frame->ownsSiteInStatement(2)); + $this->assertTrue($frame->hasSiteAtOrAfter(1)); + $this->assertFalse($frame->hasSiteAtOrAfter(2)); + + $extended = $resolver->resolve($constraints->withSite(self::markerOfA($inThird, null)), null, [0, 10, 20]); + $this->assertTrue($extended->ownsSiteInStatement(2)); + $this->assertSame(1, $extended->firstSiteStatementIndex()); + $this->assertFalse($frame->ownsSiteInStatement(2)); + + // An unpositioned site conservatively starts the re-walk at the beginning. + $extended = $resolver->resolve($constraints->withSite(self::markerOfA(new Variable('c'), null)), null, [0, 10, 20]); + $this->assertSame(0, $extended->firstSiteStatementIndex()); + } + +} diff --git a/tests/PHPStan/Analyser/Generics/data/constraint-flow.php b/tests/PHPStan/Analyser/Generics/data/constraint-flow.php new file mode 100644 index 00000000000..8b5a09267a1 --- /dev/null +++ b/tests/PHPStan/Analyser/Generics/data/constraint-flow.php @@ -0,0 +1,79 @@ + $box */ +function consume(Box $box): bool +{ + return true; +} + +function returningBranch(bool $condition): void +{ + $box = new Box(); + assertType('TemplateArgumentConstraintFlow\Box', $box); + if ($condition) { + consume($box); + return; + } + assertType('TemplateArgumentConstraintFlow\Box', $box); +} + +function throwingBranch(bool $condition): void +{ + $box = new Box(); + try { + if ($condition) { + consume($box); + throw new \RuntimeException(); + } + } catch (\RuntimeException $e) { + } finally { + assertType('TemplateArgumentConstraintFlow\Box', $box); + } + assertType('TemplateArgumentConstraintFlow\Box', $box); +} + +function loop(bool $condition): void +{ + $box = new Box(); + while ($condition) { + $box->add(1); + break; + } + assertType('TemplateArgumentConstraintFlow\Box<1>', $box); +} + +function closure(): void +{ + $box = new Box(); + $callback = function () use ($box): void { + consume($box); + }; + assertType('TemplateArgumentConstraintFlow\Box', $box); +} + +function arrow(): void +{ + $box = new Box(); + $callback = fn () => consume($box); + assertType('TemplateArgumentConstraintFlow\Box', $box); +} + +function shortCircuit(bool $condition): void +{ + $box = new Box(); + $condition && consume($box); + assertType('TemplateArgumentConstraintFlow\Box', $box); +} diff --git a/tests/PHPStan/Analyser/Generics/data/minimal-rewalk.php b/tests/PHPStan/Analyser/Generics/data/minimal-rewalk.php new file mode 100644 index 00000000000..01fd1f288be --- /dev/null +++ b/tests/PHPStan/Analyser/Generics/data/minimal-rewalk.php @@ -0,0 +1,48 @@ + $items */ + public function __construct(array $items = []) + { + } + + /** @param T $item */ + public function add($item): void + { + } + +} + +class Foo +{ + + /** @var Collection */ + private Collection $ints; + + public function doFoo(int $x): void + { + $c = new Collection([1]); + $this->ints = $c; + assertType('MinimalReWalk\Collection', $c); + $a = $x + 1; + $b = $a * 2; + $d = $b - 1; + $e = $d + $a; + $f = [$a, $b]; + $g = $f[0] + $e; + $h = $g > 3 ? $g : 3; + $i = $h + 1; + $j = $i * $x; + assertType('int', $j); + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/assert-class-type.php b/tests/PHPStan/Analyser/nsrt/assert-class-type.php index 5bb97f4fce2..4db67141dba 100644 --- a/tests/PHPStan/Analyser/nsrt/assert-class-type.php +++ b/tests/PHPStan/Analyser/nsrt/assert-class-type.php @@ -33,10 +33,10 @@ public function assert($data): void function () { $a = new HelloWorld(123); - assertType('AssertClassType\\HelloWorld', $a); + assertType('AssertClassType\\HelloWorld<123>', $a); $b = $_GET['value']; $a->assert($b); - assertType('int', $b); + assertType('123', $b); }; diff --git a/tests/PHPStan/Analyser/nsrt/bug-10254.php b/tests/PHPStan/Analyser/nsrt/bug-10254.php index a16ed81f044..fb55db50163 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-10254.php +++ b/tests/PHPStan/Analyser/nsrt/bug-10254.php @@ -70,22 +70,22 @@ function (): void { $value = Option::some(1) ->zip(Option::some(2)); - assertType('Bug10254\\Option', $value); + assertType('Bug10254\\Option', $value); $value1 = $value->map(function ($value) { - assertType('int', $value[0]); - assertType('int', $value[1]); + assertType('1', $value[0]); + assertType('2', $value[1]); return $value[0] + $value[1]; }); - assertType('Bug10254\\Option', $value1); + assertType('Bug10254\\Option<3>', $value1); $value2 = $value->map(function ($value): int { - assertType('int', $value[0]); - assertType('int', $value[1]); + assertType('1', $value[0]); + assertType('2', $value[1]); return $value[0] + $value[1]; }); - assertType('Bug10254\\Option', $value2); + assertType('Bug10254\\Option<3>', $value2); }; diff --git a/tests/PHPStan/Analyser/nsrt/bug-14203.php b/tests/PHPStan/Analyser/nsrt/bug-14203.php index d35577f07a1..de41d1216ae 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14203.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14203.php @@ -63,7 +63,7 @@ function works(): void { $myCollection = new Collection([new SpecificA(1, 'A'), new SpecificB(2, 'B')]); $result = $myCollection->map(static fn (SpecificA|SpecificB $specific): MyDTO => new MyDTO($specific->someSharedValue)); - assertType('Bug14203\Collection', $result); + assertType('Bug14203\Collection<0|1, Bug14203\MyDTO>', $result); } /** diff --git a/tests/PHPStan/Analyser/nsrt/bug-15147.php b/tests/PHPStan/Analyser/nsrt/bug-15147.php new file mode 100644 index 00000000000..83a40fc86b8 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15147.php @@ -0,0 +1,34 @@ +|mixed> $alias + */ + public function populate(ArrayObject $alias): void + { + } + + public function test(): void + { + $alias = new ArrayObject(); + $this->populate($alias); + // the parameter's value slot accepts anything, so it decides nothing - + // but the object is in use, so the template's bound stands instead of + // the never an untouched `new ArrayObject()` would resolve to + assertType('ArrayObject<(int|string), mixed>', $alias); + } + + public function untouched(): void + { + $alias = new ArrayObject(); + assertType('ArrayObject<*NEVER*, *NEVER*>', $alias); + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-5508.php b/tests/PHPStan/Analyser/nsrt/bug-5508.php index 89af5b4b98a..e2bc3b0e510 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-5508.php +++ b/tests/PHPStan/Analyser/nsrt/bug-5508.php @@ -53,5 +53,5 @@ function (): void { return $category; })->all(); - assertType('array', $result); + assertType('array<0|1, \'book\'|\'cars\'>', $result); }; diff --git a/tests/PHPStan/Analyser/nsrt/bug-6695.php b/tests/PHPStan/Analyser/nsrt/bug-6695.php index 396548a4aa8..799dabb1512 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-6695.php +++ b/tests/PHPStan/Analyser/nsrt/bug-6695.php @@ -11,7 +11,7 @@ enum Foo: int public function toCollection(): void { - assertType('Bug6695\Collection', $this->collect(self::cases())); + assertType('Bug6695\Collection<0|1, Bug6695\Foo::BAR|Bug6695\Foo::BAZ>', $this->collect(self::cases())); } /** diff --git a/tests/PHPStan/Analyser/nsrt/bug-6732.php b/tests/PHPStan/Analyser/nsrt/bug-6732.php new file mode 100644 index 00000000000..21a37f6c751 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-6732.php @@ -0,0 +1,280 @@ += 8.0 + +namespace Bug6732; + +use function PHPStan\Testing\assertType; + +/** @template T */ +class Collection +{ + + /** @param array $items */ + public function __construct(array $items = []) + { + } + + /** @param T $item */ + public function add($item): void + { + } + + /** @return T */ + public function get() + { + } + +} + +/** @template T */ +class Bag +{ + + public function __construct() + { + } + + /** @param T $item */ + public function add($item): void + { + } + + /** @return T */ + public function get() + { + } + +} + +/** @template-covariant T */ +class Box +{ + + /** @param T $value */ + public function __construct($value) + { + } + +} + +/** @template-covariant T */ +class EmptyBox +{ + + public function __construct() + { + } + +} + +/** @param Collection $ints */ +function takeInts(Collection $ints): void +{ +} + +/** @param Collection $strings */ +function takeStrings(Collection $strings): void +{ +} + +/** @param Collection $c */ +function takeCovariantInts(Collection $c): void +{ +} + +/** @param Collection $c */ +function takeContravariantInts(Collection $c): void +{ +} + +/** @param Collection<*> $c */ +function takeAny(Collection $c): void +{ +} + +/** @param Bag $b */ +function takeBagOfInts(Bag $b): void +{ +} + +/** @param Box $b */ +function takeBoxOfInts(Box $b): void +{ +} + +/** @param EmptyBox $b */ +function takeEmptyBoxOfInts(EmptyBox $b): void +{ +} + +/** + * @template T + * @param T $item + * @return Collection + */ +function make($item): Collection +{ +} + +/** + * @template T + * @param Collection $c + * @return Bag + */ +function wrap(Collection $c): Bag +{ +} + +class Sends +{ + + /** @var Collection */ + private Collection $ints; + + /** @var Collection */ + private Collection $strings; + + /** @var Bag */ + private Bag $bagOfInts; + + public function propertySend(): void + { + $c = new Collection([1]); + assertType('Bug6732\Collection', $c); + $this->ints = $c; + assertType('Bug6732\Collection', $c); + } + + public function firstSendWins(): void + { + $array = new Collection([]); + $this->ints = $array; + $this->strings = $array; + assertType('Bug6732\Collection', $array); + } + + /** @return Collection */ + public function returnSend(): Collection + { + $c = new Collection([1]); + assertType('Bug6732\Collection', $c); + + return $c; + } + + public function sendWinsOverLowerBound(): void + { + $b = new Bag(); + $b->add(1); + $this->bagOfInts = $b; + $b->add('a'); + assertType('Bug6732\Bag', $b); + } + +} + +function (): void { + $c = new Collection([1]); + takeInts($c); + assertType('Bug6732\Collection', $c); +}; + +function (): void { + $c = new Collection([1]); + takeStrings($c); + assertType('Bug6732\Collection<1>', $c); +}; + +function (): void { + $ints = new Collection([1]); + /** @var Collection $x */ + $x = $ints; + assertType('Bug6732\Collection', $ints); +}; + +function (): void { + $c = new Collection([1]); + $f = function () use ($c): void { + takeInts($c); + }; + assertType('Bug6732\Collection', $c); +}; + +function (): void { + $c = new Collection([1]); + $f = fn () => takeInts($c); + assertType('Bug6732\Collection', $c); +}; + +function (bool $foo): void { + $c = new Collection([1]); + takeInts($foo ? $c : new Collection([2])); + assertType('Bug6732\Collection', $c); +}; + +function (): void { + $c = new Collection([1]); + assertType('Bug6732\Collection<1>', $c); + assertType('1', $c->get()); +}; + +function (): void { + $b = new Bag(); + assertType('Bug6732\Bag<*NEVER*>', $b); + $c = new Collection(); + assertType('Bug6732\Collection<*NEVER*>', $c); + $e = new Collection([]); + assertType('Bug6732\Collection<*NEVER*>', $e); +}; + +function (): void { + $b = new Bag(); + $b->add(1); + $b->add('a'); + assertType("Bug6732\Bag<1|'a'>", $b); + assertType("1|'a'", $b->get()); +}; + +function (): void { + $b = new Bag(); + takeBagOfInts($b); + assertType('Bug6732\Bag', $b); +}; + +function (): void { + $x = new Box(1); + takeBoxOfInts($x); + assertType('Bug6732\Box<1>', $x); + + $e = new EmptyBox(); + takeEmptyBoxOfInts($e); + assertType('Bug6732\EmptyBox', $e); +}; + +function (): void { + $a = new Collection([1]); + takeCovariantInts($a); + assertType('Bug6732\Collection<1>', $a); + + $b = new Collection([1]); + takeContravariantInts($b); + assertType('Bug6732\Collection', $b); + + $c = new Collection([1]); + takeAny($c); + assertType('Bug6732\Collection<1>', $c); +}; + +function (): void { + $c = make(1); + takeInts($c); + assertType('Bug6732\Collection', $c); + assertType('Bug6732\Collection<2>', make(2)); +}; + +function (): void { + $c = new Collection([1]); + $b = wrap($c); + takeBagOfInts($b); + assertType('Bug6732\Collection', $c); + assertType('Bug6732\Bag', $b); +}; diff --git a/tests/PHPStan/Analyser/nsrt/bug-6993.php b/tests/PHPStan/Analyser/nsrt/bug-6993.php index 5f427f3f281..2f601ee82c4 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-6993.php +++ b/tests/PHPStan/Analyser/nsrt/bug-6993.php @@ -76,7 +76,7 @@ class Bar function (): void { $and = (new AndSpecificationValidator([new TestSpecification()])); - assertType('Bug6993\AndSpecificationValidator', $and); + assertType('Bug6993\AndSpecificationValidator', $and); $and->isSatisfiedBy(new Foo()); $and->isSatisfiedBy(new Bar()); }; diff --git a/tests/PHPStan/Analyser/nsrt/bug-7788.php b/tests/PHPStan/Analyser/nsrt/bug-7788.php index fa5c6a73aff..7f91fe1aeb2 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-7788.php +++ b/tests/PHPStan/Analyser/nsrt/bug-7788.php @@ -30,5 +30,5 @@ public function getProp(string $propKey, mixed $default = null): mixed } function () { - assertType('int', (new Props(['title' => 'test', 'value' => 30]))->getProp('value', 0)); + assertType('0|30', (new Props(['title' => 'test', 'value' => 30]))->getProp('value', 0)); }; diff --git a/tests/PHPStan/Analyser/nsrt/bug-8441.php b/tests/PHPStan/Analyser/nsrt/bug-8441.php index 7d04216b21e..24da82564d7 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-8441.php +++ b/tests/PHPStan/Analyser/nsrt/bug-8441.php @@ -143,8 +143,8 @@ public function collection($x = null): Collection } function (?int $nullOrInt, int $int, Service $service): void { - assertType('Bug8441\Collection', new Collection()); - assertType('Bug8441\Collection', new Collection(null)); + assertType('Bug8441\Collection<*NEVER*>', new Collection()); + assertType('Bug8441\Collection<*NEVER*>', new Collection(null)); assertType('Bug8441\Collection', new Collection($nullOrInt)); assertType('Bug8441\Collection', new Collection($int)); assertType('Bug8441\CollectionWithNonNullableParam', new CollectionWithNonNullableParam()); diff --git a/tests/PHPStan/Analyser/nsrt/bug-8540.php b/tests/PHPStan/Analyser/nsrt/bug-8540.php new file mode 100644 index 00000000000..1d35a9a03cd --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-8540.php @@ -0,0 +1,36 @@ +', $storage); +} + +function attach(): void +{ + $storage = new SplObjectStorage(); + $storage->attach(new stdClass(), 'data'); + assertType('SplObjectStorage', $storage); +} + +function explicitOffsetSet(): void +{ + $storage = new SplObjectStorage(); + $storage->offsetSet(new stdClass(), 'data'); + assertType('SplObjectStorage', $storage); +} + +function severalWrites(): void +{ + $storage = new SplObjectStorage(); + $storage[new stdClass()] = 'data'; + $storage[new \DateTimeImmutable()] = 17; + assertType('SplObjectStorage', $storage); +} diff --git a/tests/PHPStan/Analyser/nsrt/ext-ds.php b/tests/PHPStan/Analyser/nsrt/ext-ds.php index f451971c0ce..4e75b3f6857 100644 --- a/tests/PHPStan/Analyser/nsrt/ext-ds.php +++ b/tests/PHPStan/Analyser/nsrt/ext-ds.php @@ -56,7 +56,7 @@ public function mapMerge() : void { $a = new Map([1 => new A()]); - assertType('Ds\Map', $a->merge(['a' => new B()])); + assertType('Ds\Map<1|\'a\', ExtDs\A|ExtDs\B>', $a->merge(['a' => new B()])); } public function mapUnion() : void @@ -64,7 +64,7 @@ public function mapUnion() : void $a = new Map([1 => new A()]); $b = new Map(['a' => new B()]); - assertType('Ds\Map', $a->union($b)); + assertType('Ds\Map<1|\'a\', ExtDs\A|ExtDs\B>', $a->union($b)); } public function mapXor() : void @@ -72,7 +72,7 @@ public function mapXor() : void $a = new Map([1 => new A()]); $b = new Map(['a' => new B()]); - assertType('Ds\Map', $a->xor($b)); + assertType('Ds\Map<1|\'a\', ExtDs\A|ExtDs\B>', $a->xor($b)); } public function setMerge() : void diff --git a/tests/PHPStan/Analyser/nsrt/generic-static.php b/tests/PHPStan/Analyser/nsrt/generic-static.php index c7163151f72..d779e30114f 100644 --- a/tests/PHPStan/Analyser/nsrt/generic-static.php +++ b/tests/PHPStan/Analyser/nsrt/generic-static.php @@ -54,7 +54,7 @@ public function fluent() public function doFoo(): void { - assertType('static(GenericStatic\FooImpl)', $this->map(function () { + assertType('static(GenericStatic\FooImpl)', $this->map(function () { return 1; })); @@ -68,7 +68,7 @@ public function doFoo(): void */ public function doBar(self $s): void { - assertType('GenericStatic\\FooImpl', $s->map(function () { + assertType('GenericStatic\\FooImpl', $s->map(function () { return 1; })); diff --git a/tests/PHPStan/Analyser/nsrt/generics-do-not-generalize.php b/tests/PHPStan/Analyser/nsrt/generics-do-not-generalize.php index d00b8b699a1..6a76413727d 100644 --- a/tests/PHPStan/Analyser/nsrt/generics-do-not-generalize.php +++ b/tests/PHPStan/Analyser/nsrt/generics-do-not-generalize.php @@ -39,8 +39,8 @@ public function __construct($p) function (): void { assertType('array<1>', test(1)); - assertType('GenericsDoNotGeneralize\Foo', test2(1)); - assertType('GenericsDoNotGeneralize\Foo', new Foo(1)); + assertType('GenericsDoNotGeneralize\Foo<1>', test2(1)); + assertType('GenericsDoNotGeneralize\Foo<1>', new Foo(1)); }; class Test @@ -97,7 +97,7 @@ function (): void { /** @var list $a */ $a = doFoo(); - assertType('ArrayIterator', new ArrayIterator($a)); + assertType('ArrayIterator, string>', new ArrayIterator($a)); }; /** @@ -115,7 +115,7 @@ function (): void { /** @var list $a */ $a = doFoo(); - assertType('ArrayIterator', createArrayIterator($a)); + assertType('ArrayIterator, string>', createArrayIterator($a)); }; /** @template T */ @@ -143,6 +143,6 @@ public function __construct($p) } function (): void { - assertType('GenericsDoNotGeneralize\\FooInvariant', new FooInvariant(1)); + assertType('GenericsDoNotGeneralize\\FooInvariant<1>', new FooInvariant(1)); assertType('GenericsDoNotGeneralize\\FooCovariant<1>', new FooCovariant(1)); }; diff --git a/tests/PHPStan/Analyser/nsrt/generics-empty-array.php b/tests/PHPStan/Analyser/nsrt/generics-empty-array.php index b238f3fdbf6..e8da03c077c 100644 --- a/tests/PHPStan/Analyser/nsrt/generics-empty-array.php +++ b/tests/PHPStan/Analyser/nsrt/generics-empty-array.php @@ -73,8 +73,8 @@ class Baz public function doFoo() { - assertType('GenericsEmptyArray\\ArrayCollection2<(int|string), mixed>', new ArrayCollection2()); - assertType('GenericsEmptyArray\\ArrayCollection2<(int|string), mixed>', new ArrayCollection2([])); + assertType('GenericsEmptyArray\\ArrayCollection2<(int|string), *NEVER*>', new ArrayCollection2()); + assertType('GenericsEmptyArray\\ArrayCollection2<(int|string), *NEVER*>', new ArrayCollection2([])); } } diff --git a/tests/PHPStan/Analyser/nsrt/generics.php b/tests/PHPStan/Analyser/nsrt/generics.php index 9a01501f53a..5ab887874f3 100644 --- a/tests/PHPStan/Analyser/nsrt/generics.php +++ b/tests/PHPStan/Analyser/nsrt/generics.php @@ -717,9 +717,9 @@ public function create($a, $c, $d): array function testClasses() { $a = new A(1); - assertType('PHPStan\Generics\FunctionsAssertType\A', $a); - assertType('int', $a->get()); - assertType('int', $a->b); + assertType('PHPStan\Generics\FunctionsAssertType\A<1>', $a); + assertType('1', $a->get()); + assertType('1', $a->b); $a = new AOfDateTime(); assertType('PHPStan\Generics\FunctionsAssertType\AOfDateTime', $a); @@ -727,9 +727,9 @@ function testClasses() assertType('DateTime', $a->b); $b = new B(1); - assertType('PHPStan\Generics\FunctionsAssertType\B', $b); - assertType('int', $b->get()); - assertType('int', $b->b); + assertType('PHPStan\Generics\FunctionsAssertType\B<1>', $b); + assertType('1', $b->get()); + assertType('1', $b->b); $c = new CofI(); assertType('PHPStan\Generics\FunctionsAssertType\CofI', $c); @@ -741,7 +741,7 @@ function testClasses() assertType('DateTime', $ab->getB(new \DateTime())); $noConstructor = new NoConstructor(1); - assertType('PHPStan\Generics\FunctionsAssertType\NoConstructor', $noConstructor); + assertType('PHPStan\Generics\FunctionsAssertType\NoConstructor<1>', $noConstructor); assertType('stdClass', acceptsClassString(\stdClass::class)); assertType('class-string', returnsClassString(new \stdClass())); @@ -763,7 +763,7 @@ function testClasses() $factory = new Factory(new \DateTime(), new A(1)); assertType( - 'array{DateTime, PHPStan\\Generics\\FunctionsAssertType\\A, \'\', PHPStan\\Generics\\FunctionsAssertType\\A}', + 'array{DateTime, PHPStan\\Generics\\FunctionsAssertType\\A<1>, \'\', PHPStan\\Generics\\FunctionsAssertType\\A}', $factory->create(new \DateTime(), '', new A(new \DateTime())) ); } @@ -887,9 +887,9 @@ function cache1($t): void { } function newHandling(): void { - assertType('PHPStan\Generics\FunctionsAssertType\C', new C()); + assertType('PHPStan\Generics\FunctionsAssertType\C<*NEVER*>', new C()); assertType('PHPStan\Generics\FunctionsAssertType\A', new A(new \stdClass())); - assertType('PHPStan\Generics\FunctionsAssertType\A', new A()); + assertType('PHPStan\Generics\FunctionsAssertType\A<*NEVER*>', new A()); } /** @@ -934,9 +934,9 @@ function () { assertType('array{}', $stdEmpty->getAll()); $std = new StdClassCollection([new \stdClass()]); - assertType('PHPStan\Generics\FunctionsAssertType\StdClassCollection', $std); - assertType('PHPStan\Generics\FunctionsAssertType\StdClassCollection', $std->returnStatic()); - assertType('array', $std->getAll()); + assertType('PHPStan\Generics\FunctionsAssertType\StdClassCollection<0, stdClass>', $std); + assertType('PHPStan\Generics\FunctionsAssertType\StdClassCollection<0, stdClass>', $std->returnStatic()); + assertType('array<0, stdClass>', $std->getAll()); }; class ClassWithMethodCachingIssue diff --git a/tests/PHPStan/Analyser/nsrt/native-reflection-default-values.php b/tests/PHPStan/Analyser/nsrt/native-reflection-default-values.php index 5d0b8cdeabe..49565e5f360 100644 --- a/tests/PHPStan/Analyser/nsrt/native-reflection-default-values.php +++ b/tests/PHPStan/Analyser/nsrt/native-reflection-default-values.php @@ -7,5 +7,5 @@ function () { assertType('ArrayObject<*NEVER*, *NEVER*>', new \ArrayObject()); assertType('ArrayObject<*NEVER*, *NEVER*>', new \ArrayObject([])); - assertType('ArrayObject', new \ArrayObject(['key' => 1])); + assertType('ArrayObject<\'key\', 1>', new \ArrayObject(['key' => 1])); }; diff --git a/tests/PHPStan/Analyser/nsrt/nested-generic-incomplete-constructor.php b/tests/PHPStan/Analyser/nsrt/nested-generic-incomplete-constructor.php index 847936097c3..cd39babad9f 100644 --- a/tests/PHPStan/Analyser/nsrt/nested-generic-incomplete-constructor.php +++ b/tests/PHPStan/Analyser/nsrt/nested-generic-incomplete-constructor.php @@ -30,6 +30,6 @@ public function __construct($t) function (): void { $foo = new Foo(1); //assertType('NestedGenericIncompleteConstructor\Foo', $foo); - assertType('int', $foo->t); - assertType('int', $foo->u); + assertType('1', $foo->t); + assertType('1', $foo->u); }; diff --git a/tests/PHPStan/Analyser/nsrt/node-callback-scope.php b/tests/PHPStan/Analyser/nsrt/node-callback-scope.php index 85519a3c71c..cfd4e7cb56d 100644 --- a/tests/PHPStan/Analyser/nsrt/node-callback-scope.php +++ b/tests/PHPStan/Analyser/nsrt/node-callback-scope.php @@ -450,7 +450,7 @@ public function __construct($a) function (): void { $foo = new FooGeneric(5); - assertType('NodeCallbackScope\\FooGeneric', $foo); + assertType('NodeCallbackScope\\FooGeneric<5>', $foo); }; function (): void { diff --git a/tests/PHPStan/Analyser/nsrt/self-out.php b/tests/PHPStan/Analyser/nsrt/self-out.php index fa623d2d5a8..48af21fe7d3 100644 --- a/tests/PHPStan/Analyser/nsrt/self-out.php +++ b/tests/PHPStan/Analyser/nsrt/self-out.php @@ -76,8 +76,8 @@ public function __construct($data) { function () { $i = new a(123); // OK - $i is a<123> - assertType('SelfOut\\a', $i); - assertType('null', $i->test()); + assertType('SelfOut\\a<123>', $i); + assertType('never', $i->test()); $i->addData(321); // OK - $i is a<123|321> @@ -92,13 +92,13 @@ function () { function () { $i = new b(123); - assertType('SelfOut\\b', $i); + assertType('SelfOut\\b<123>', $i); $i->addData(321); - assertType('SelfOut\\a', $i); + assertType('SelfOut\\a<123|321>', $i); $i->addData(random_bytes(3)); - assertType('SelfOut\\a', $i); + assertType('SelfOut\\a<123|321|non-empty-string>', $i); $i->setData(true); assertType('SelfOut\\a', $i); diff --git a/tests/PHPStan/Analyser/nsrt/unresolved-template-argument-never.php b/tests/PHPStan/Analyser/nsrt/unresolved-template-argument-never.php new file mode 100644 index 00000000000..3053ba36a7b --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/unresolved-template-argument-never.php @@ -0,0 +1,80 @@ + */ + private $tt; + + public function __construct() + { + $this->tt = new TT([]); + assertType('UnresolvedTemplateArgumentNever\TT', $this->tt); + } + +} + +/** + * @template TGet + * @template TSet + */ +class Attribute +{ + + /** @var (callable(mixed, array): TGet)|null */ + public $get; + + /** @var (callable(TSet, array): mixed)|null */ + public $set; + + /** + * @param (callable(mixed, array): TGet)|null $get + * @param (callable(TSet, array): mixed)|null $set + */ + public function __construct(?callable $get = null, ?callable $set = null) + { + $this->get = $get; + $this->set = $set; + } + + /** + * @template T + * @param callable(mixed, array): T $get + * @return Attribute + */ + public static function get(callable $get): self + { + $attribute = new self($get); + assertType('UnresolvedTemplateArgumentNever\Attribute', $attribute); + + return $attribute; + } + + /** + * @template T + * @param callable(T, array): mixed $set + * @return Attribute + */ + public static function set(callable $set): self + { + return new self(null, $set); + } + +} diff --git a/tests/PHPStan/Generics/data/classes-5.json b/tests/PHPStan/Generics/data/classes-5.json index 4795e0f93cf..9d0a7fb08d7 100644 --- a/tests/PHPStan/Generics/data/classes-5.json +++ b/tests/PHPStan/Generics/data/classes-5.json @@ -1,19 +1,9 @@ [ - { - "message": "Parameter #1 $a of method PHPStan\\Generics\\Classes\\A::set() expects int, string given.", - "line": 261, - "ignorable": true - }, { "message": "Parameter #1 $a of method PHPStan\\Generics\\Classes\\A::set() expects DateTime, int given.", "line": 266, "ignorable": true }, - { - "message": "Parameter #1 $a of method PHPStan\\Generics\\Classes\\A::set() expects int, string given.", - "line": 271, - "ignorable": true - }, { "message": "Parameter #1 $a of method PHPStan\\Generics\\Classes\\C::set() expects int, string given.", "line": 276, diff --git a/tests/PHPStan/Internal/LruCacheTest.php b/tests/PHPStan/Internal/LruCacheTest.php index 079d6ad799e..5f56b631479 100644 --- a/tests/PHPStan/Internal/LruCacheTest.php +++ b/tests/PHPStan/Internal/LruCacheTest.php @@ -11,6 +11,9 @@ class LruCacheTest extends PHPStanTestCase public function testMissingEntry(): void { + // nothing ever put in: without a declared value type the cache is + // LruCache and get() is statically null + /** @var LruCache $cache */ $cache = new LruCache(); $this->assertNull($cache->get('a')); diff --git a/tests/PHPStan/Levels/data/arrayAccess-10.json b/tests/PHPStan/Levels/data/arrayAccess-10.json index 9dc3ca3eb92..30ba124708b 100644 --- a/tests/PHPStan/Levels/data/arrayAccess-10.json +++ b/tests/PHPStan/Levels/data/arrayAccess-10.json @@ -1,6 +1,6 @@ [ { - "message": "Cannot assign offset mixed to SplObjectStorage.", + "message": "Cannot assign offset mixed to SplObjectStorage.", "line": 43, "ignorable": true } diff --git a/tests/PHPStan/Levels/data/arrayAccess-3.json b/tests/PHPStan/Levels/data/arrayAccess-3.json index dcc1810c46e..4916228404e 100644 --- a/tests/PHPStan/Levels/data/arrayAccess-3.json +++ b/tests/PHPStan/Levels/data/arrayAccess-3.json @@ -1,7 +1,27 @@ [ { - "message": "Cannot assign offset int to SplObjectStorage.", + "message": "SplObjectStorage does not accept int.", + "line": 16, + "ignorable": true + }, + { + "message": "SplObjectStorage does not accept int.", + "line": 27, + "ignorable": true + }, + { + "message": "Cannot assign offset int to SplObjectStorage.", "line": 35, "ignorable": true + }, + { + "message": "SplObjectStorage does not accept int.", + "line": 35, + "ignorable": true + }, + { + "message": "SplObjectStorage does not accept int.", + "line": 43, + "ignorable": true } -] +] \ No newline at end of file diff --git a/tests/PHPStan/Levels/data/arrayAccess-7.json b/tests/PHPStan/Levels/data/arrayAccess-7.json index 64c7a328fb1..58f0c685788 100644 --- a/tests/PHPStan/Levels/data/arrayAccess-7.json +++ b/tests/PHPStan/Levels/data/arrayAccess-7.json @@ -1,7 +1,7 @@ [ { - "message": "Cannot assign offset int|object to SplObjectStorage.", + "message": "Cannot assign offset int|object to SplObjectStorage.", "line": 27, "ignorable": true } -] +] \ No newline at end of file diff --git a/tests/PHPStan/Rules/Arrays/OffsetAccessAssignmentRuleTest.php b/tests/PHPStan/Rules/Arrays/OffsetAccessAssignmentRuleTest.php index 10bf75e504d..f54f13052bc 100644 --- a/tests/PHPStan/Rules/Arrays/OffsetAccessAssignmentRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/OffsetAccessAssignmentRuleTest.php @@ -69,7 +69,7 @@ public function testOffsetAccessAssignmentToScalar(): void 68, ], [ - 'Cannot assign offset array{1, 2, 3} to SplObjectStorage.', + 'Cannot assign offset array{1, 2, 3} to SplObjectStorage.', 72, ], [ @@ -111,7 +111,7 @@ public function testOffsetAccessAssignmentToScalarWithoutMaybes(): void 68, ], [ - 'Cannot assign offset array{1, 2, 3} to SplObjectStorage.', + 'Cannot assign offset array{1, 2, 3} to SplObjectStorage.', 72, ], [ diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index 7688ec38568..7dff486519c 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -990,11 +990,11 @@ public function testBug10502(): void $this->treatPhpDocTypesAsCertain = true; $this->analyse([__DIR__ . '/data/bug-10502.php'], [ [ - "Call to function is_callable() with array{ArrayObject, 'count'} will always evaluate to true.", + "Call to function is_callable() with array{ArrayObject<0, 0>, 'count'} will always evaluate to true.", 23, ], [ - "Call to function is_callable() with array{1: 'count', 0: ArrayObject} will always evaluate to true.", + "Call to function is_callable() with array{1: 'count', 0: ArrayObject<0, 0>} will always evaluate to true.", 24, $tipText, ], diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index e67db07f3a1..75e5828e59a 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -3117,4 +3117,14 @@ public function testBug15168(): void $this->analyse([__DIR__ . '/data/bug-15168.php'], []); } + public function testBug6732(): void + { + $this->analyse([__DIR__ . '/data/bug-6732.php'], [ + [ + 'Parameter #1 $strings of function Bug6732Functions\takeStrings expects Bug6732Functions\Collection, Bug6732Functions\Collection given.', + 29, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php index 88a4b5562cf..8167add9c7c 100644 --- a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php @@ -117,7 +117,7 @@ public function testBug2723(): void $this->checkNullables = true; $this->analyse([__DIR__ . '/data/bug-2723.php'], [ [ - 'Function Bug2723\baz() should return Bug2723\Bar> but returns Bug2723\BarOfFoo.', + 'Function Bug2723\baz() should return Bug2723\Bar> but returns Bug2723\BarOfFoo<\'hello\'>.', 55, ], ]); diff --git a/tests/PHPStan/Rules/Functions/data/bug-6732.php b/tests/PHPStan/Rules/Functions/data/bug-6732.php new file mode 100644 index 00000000000..29febddbfd9 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-6732.php @@ -0,0 +1,30 @@ + $items */ + public function __construct(array $items = []) + { + } + +} + +/** @param Collection $ints */ +function takeInts(Collection $ints): void +{ +} + +/** @param Collection $strings */ +function takeStrings(Collection $strings): void +{ +} + +function (): void { + $ints = new Collection(); + takeInts($ints); + takeStrings($ints); +}; diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index dfccfea405a..7f561aa99ff 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -2354,33 +2354,7 @@ public function testBug5372(): void $this->checkThisOnly = false; $this->checkNullables = true; $this->checkUnionTypes = true; - $this->analyse([__DIR__ . '/data/bug-5372.php'], [ - [ - 'Parameter #1 $list of method Bug5372\Foo::takesStrings() expects Bug5372\Collection, Bug5372\Collection given.', - 64, - 'Template type T on class Bug5372\Collection is not covariant. Learn more: https://phpstan.org/blog/whats-up-with-template-covariant', - ], - [ - 'Parameter #1 $list of method Bug5372\Foo::takesStrings() expects Bug5372\Collection, Bug5372\Collection given.', - 68, - 'Template type T on class Bug5372\Collection is not covariant. Learn more: https://phpstan.org/blog/whats-up-with-template-covariant', - ], - [ - 'Parameter #1 $list of method Bug5372\Foo::takesStrings() expects Bug5372\Collection, Bug5372\Collection given.', - 72, - 'Template type T on class Bug5372\Collection is not covariant. Learn more: https://phpstan.org/blog/whats-up-with-template-covariant', - ], - [ - 'Parameter #1 $list of method Bug5372\Foo::takesStrings() expects Bug5372\Collection, Bug5372\Collection given.', - 81, - 'Template type T on class Bug5372\Collection is not covariant. Learn more: https://phpstan.org/blog/whats-up-with-template-covariant', - ], - [ - 'Parameter #1 $list of method Bug5372\Foo::takesStrings() expects Bug5372\Collection, Bug5372\Collection given.', - 85, - 'Template type T on class Bug5372\Collection is not covariant. Learn more: https://phpstan.org/blog/whats-up-with-template-covariant', - ], - ]); + $this->analyse([__DIR__ . '/data/bug-5372.php'], []); } public function testLiteralString(): void @@ -2697,21 +2671,9 @@ public function testGenericsInferCollection(): void $this->checkExplicitMixed = true; $this->analyse([__DIR__ . '/data/generics-infer-collection.php'], [ [ - 'Parameter #1 $c of method GenericsInferCollection\Foo::doBar() expects GenericsInferCollection\ArrayCollection, GenericsInferCollection\ArrayCollection given.', + 'Parameter #1 $c of method GenericsInferCollection\Foo::doBar() expects GenericsInferCollection\ArrayCollection, GenericsInferCollection\ArrayCollection given.', 43, ], - [ - 'Parameter #1 $c of method GenericsInferCollection\Bar::doBar() expects GenericsInferCollection\ArrayCollection2, GenericsInferCollection\ArrayCollection2<(int|string), mixed> given.', - 62, - ], - [ - 'Parameter #1 $c of method GenericsInferCollection\Bar::doBar() expects GenericsInferCollection\ArrayCollection2, GenericsInferCollection\ArrayCollection2<(int|string), mixed> given.', - 63, - ], - [ - 'Parameter #1 $c of method GenericsInferCollection\Bar::doBar() expects GenericsInferCollection\ArrayCollection2, GenericsInferCollection\ArrayCollection2<(int|string), mixed> given.', - 64, - ], ]); } @@ -2723,7 +2685,7 @@ public function testGenericsInferCollectionLevel8(): void $this->checkExplicitMixed = false; $this->analyse([__DIR__ . '/data/generics-infer-collection.php'], [ [ - 'Parameter #1 $c of method GenericsInferCollection\Foo::doBar() expects GenericsInferCollection\ArrayCollection, GenericsInferCollection\ArrayCollection given.', + 'Parameter #1 $c of method GenericsInferCollection\Foo::doBar() expects GenericsInferCollection\ArrayCollection, GenericsInferCollection\ArrayCollection given.', 43, ], ]); @@ -4378,7 +4340,7 @@ public function testBug8441(): void $this->checkUnionTypes = true; $this->analyse([__DIR__ . '/data/bug-8441.php'], [ [ - 'Parameter #1 $c of method Bug8441Methods\\Consumer::takeInts() expects Bug8441Methods\\Collection, Bug8441Methods\\Collection given.', + 'Parameter #1 $c of method Bug8441Methods\\Consumer::takeInts() expects Bug8441Methods\\Collection, Bug8441Methods\\Collection<\'foo\'> given.', 77, ], [ @@ -4390,7 +4352,7 @@ public function testBug8441(): void 80, ], [ - 'Parameter #1 $c of method Bug8441Methods\\Consumer::takeInts() expects Bug8441Methods\\Collection, Bug8441Methods\\Collection given.', + 'Parameter #1 $c of method Bug8441Methods\\Consumer::takeInts() expects Bug8441Methods\\Collection, Bug8441Methods\\Collection<\'foo\'> given.', 86, ], [ @@ -4409,4 +4371,17 @@ public function testBug15166(): void $this->analyse([__DIR__ . '/data/bug-15166.php'], []); } + public function testBug6732(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/bug-6732.php'], [ + [ + 'Parameter #1 $item of method Bug6732Methods\\Collection::add() expects int, string given.', + 38, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php index 718a35b370b..b84c976fec9 100644 --- a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php @@ -476,25 +476,10 @@ public function testBug4590(): void { $this->analyse([__DIR__ . '/data/bug-4590.php'], [ [ - 'Method Bug4590\OkResponse::testGenericStatic() should return static(Bug4590\OkResponse>) but returns static(Bug4590\OkResponse).', + 'Method Bug4590\OkResponse::testGenericStatic() should return static(Bug4590\OkResponse>) but returns static(Bug4590\OkResponse).', 36, 'Template type T on class Bug4590\OkResponse is not covariant. Learn more: https://phpstan.org/blog/whats-up-with-template-covariant', ], - [ - 'Method Bug4590\\Controller::test1() should return Bug4590\\OkResponse> but returns Bug4590\\OkResponse.', - 47, - 'Template type T on class Bug4590\OkResponse is not covariant. Learn more: https://phpstan.org/blog/whats-up-with-template-covariant', - ], - [ - 'Method Bug4590\\Controller::test2() should return Bug4590\\OkResponse> but returns Bug4590\\OkResponse.', - 55, - 'Template type T on class Bug4590\OkResponse is not covariant. Learn more: https://phpstan.org/blog/whats-up-with-template-covariant', - ], - [ - 'Method Bug4590\\Controller::test3() should return Bug4590\\OkResponse> but returns Bug4590\\OkResponse.', - 63, - 'Template type T on class Bug4590\OkResponse is not covariant. Learn more: https://phpstan.org/blog/whats-up-with-template-covariant', - ], ]); } @@ -689,12 +674,7 @@ public function testBug5065(): void public function testBug5065ExplicitMixed(): void { $this->checkExplicitMixed = true; - $this->analyse([__DIR__ . '/data/bug-5065.php'], [ - [ - 'Method Bug5065\Collection::emptyWorkaround2() should return Bug5065\Collection but returns Bug5065\Collection<(int|string), mixed>.', - 60, - ], - ]); + $this->analyse([__DIR__ . '/data/bug-5065.php'], []); } public function testBug3400(): void diff --git a/tests/PHPStan/Rules/Methods/data/bug-5372.php b/tests/PHPStan/Rules/Methods/data/bug-5372.php index 712516416c1..36046cad164 100644 --- a/tests/PHPStan/Rules/Methods/data/bug-5372.php +++ b/tests/PHPStan/Rules/Methods/data/bug-5372.php @@ -57,18 +57,18 @@ function takesStrings(Collection $list): void { public function doFoo(string $classString) { $col = new Collection(['foo', 'bar']); - assertType('Bug5372\Collection', $col); + assertType('Bug5372\Collection', $col); $newCol = $col->map(static fn(string $var): string => $var . 'bar'); - assertType('Bug5372\Collection', $newCol); + assertType('Bug5372\Collection', $newCol); $this->takesStrings($newCol); $newCol = $col->map(static fn(string $var): string => $classString); - assertType('Bug5372\Collection', $newCol); + assertType('Bug5372\Collection', $newCol); $this->takesStrings($newCol); $newCol = $col->map2(static fn(string $var): string => $classString); - assertType('Bug5372\Collection', $newCol); + assertType('Bug5372\Collection', $newCol); $this->takesStrings($newCol); } @@ -77,11 +77,11 @@ public function doBar(string $literalString) { $col = new Collection(['foo', 'bar']); $newCol = $col->map(static fn(string $var): string => $literalString); - assertType('Bug5372\Collection', $newCol); + assertType('Bug5372\Collection', $newCol); $this->takesStrings($newCol); $newCol = $col->map2(static fn(string $var): string => $literalString); - assertType('Bug5372\Collection', $newCol); + assertType('Bug5372\Collection', $newCol); $this->takesStrings($newCol); } diff --git a/tests/PHPStan/Rules/Methods/data/bug-6732.php b/tests/PHPStan/Rules/Methods/data/bug-6732.php new file mode 100644 index 00000000000..b87c4f2865b --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-6732.php @@ -0,0 +1,41 @@ + $items */ + public function __construct(array $items = []) + { + } + + /** @param T $item */ + public function add($item): void + { + } + +} + +class Foo +{ + + /** @var Collection */ + private Collection $ints; + + public function lowerBoundsOnly(): void + { + $ints = new Collection(); + $ints->add(1); + $ints->add("a"); + } + + public function sendWinsOverLowerBound(): void + { + $c = new Collection(); + $this->ints = $c; + $c->add('a'); + } + +} diff --git a/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php b/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php index 7b7dac3eb84..3165a578280 100644 --- a/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php @@ -258,7 +258,7 @@ public static function dataReportWrongType(): iterable 14, ], [ - 'PHPDoc tag @var with type stdClass is not subtype of native type SplObjectStorage.', + 'PHPDoc tag @var with type stdClass is not subtype of native type SplObjectStorage.', 23, ], [ @@ -315,7 +315,7 @@ public static function dataReportWrongType(): iterable 14, ], [ - 'PHPDoc tag @var with type stdClass is not subtype of native type SplObjectStorage.', + 'PHPDoc tag @var with type stdClass is not subtype of native type SplObjectStorage.', 23, ], [ @@ -406,7 +406,7 @@ public static function dataReportWrongType(): iterable 14, ], [ - 'PHPDoc tag @var with type stdClass is not subtype of native type SplObjectStorage.', + 'PHPDoc tag @var with type stdClass is not subtype of native type SplObjectStorage.', 23, ], [ diff --git a/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php b/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php index f49bfbe3c1c..54b79577624 100644 --- a/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php +++ b/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php @@ -217,11 +217,7 @@ public function testBug3777(): void 95, ], [ - 'Property Bug3777\Ipsum2::$lorem2 (Bug3777\Lorem2) does not accept Bug3777\Lorem2.', - 129, - ], - [ - 'Property Bug3777\Ipsum2::$ipsum2 (Bug3777\Lorem2) does not accept Bug3777\Lorem2.', + 'Property Bug3777\Ipsum2::$ipsum2 (Bug3777\Lorem2) does not accept Bug3777\Lorem2.', 131, ], [ @@ -240,11 +236,7 @@ public function testBug3777(): void 95, ], [ - 'Static property Bug3777Static\Ipsum2::$lorem2 (Bug3777Static\Lorem2) does not accept Bug3777Static\Lorem2.', - 129, - ], - [ - 'Static property Bug3777Static\Ipsum2::$ipsum2 (Bug3777Static\Lorem2) does not accept Bug3777Static\Lorem2.', + 'Static property Bug3777Static\Ipsum2::$ipsum2 (Bug3777Static\Lorem2) does not accept Bug3777Static\Lorem2.', 131, ], [ @@ -426,7 +418,7 @@ public function testGenericObjectWithUnspecifiedTemplateTypes(): void $this->checkExplicitMixed = true; $this->analyse([__DIR__ . '/data/generic-object-unspecified-template-types.php'], [ [ - 'Property GenericObjectUnspecifiedTemplateTypes\Bar::$ints (GenericObjectUnspecifiedTemplateTypes\ArrayCollection) does not accept GenericObjectUnspecifiedTemplateTypes\ArrayCollection.', + 'Property GenericObjectUnspecifiedTemplateTypes\Bar::$ints (GenericObjectUnspecifiedTemplateTypes\ArrayCollection) does not accept GenericObjectUnspecifiedTemplateTypes\ArrayCollection.', 67, ], ]); @@ -437,7 +429,7 @@ public function testGenericObjectWithUnspecifiedTemplateTypesLevel8(): void $this->checkExplicitMixed = false; $this->analyse([__DIR__ . '/data/generic-object-unspecified-template-types.php'], [ [ - 'Property GenericObjectUnspecifiedTemplateTypes\Bar::$ints (GenericObjectUnspecifiedTemplateTypes\ArrayCollection) does not accept GenericObjectUnspecifiedTemplateTypes\ArrayCollection.', + 'Property GenericObjectUnspecifiedTemplateTypes\Bar::$ints (GenericObjectUnspecifiedTemplateTypes\ArrayCollection) does not accept GenericObjectUnspecifiedTemplateTypes\ArrayCollection.', 67, ], ]); @@ -1103,4 +1095,14 @@ public function testBug15166(): void $this->analyse([__DIR__ . '/data/bug-15166.php'], []); } + public function testBug6732(): void + { + $this->analyse([__DIR__ . '/data/bug-6732.php'], [ + [ + 'Property Bug6732\Foo::$strings (Bug6732\ArrayCollection) does not accept Bug6732\ArrayCollection.', + 37, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Properties/data/bug-3777.php b/tests/PHPStan/Rules/Properties/data/bug-3777.php index 6ac99af08bc..3982085451d 100644 --- a/tests/PHPStan/Rules/Properties/data/bug-3777.php +++ b/tests/PHPStan/Rules/Properties/data/bug-3777.php @@ -127,9 +127,9 @@ class Ipsum2 public function __construct() { $this->lorem2 = new Lorem2(new \stdClass); - assertType('Bug3777\Lorem2', $this->lorem2); + assertType('Bug3777\Lorem2', $this->lorem2); $this->ipsum2 = new Lorem2(new \Exception()); - assertType('Bug3777\Lorem2', $this->ipsum2); + assertType('Bug3777\Lorem2', $this->ipsum2); } } diff --git a/tests/PHPStan/Rules/Properties/data/bug-6732.php b/tests/PHPStan/Rules/Properties/data/bug-6732.php new file mode 100644 index 00000000000..0629b5bce90 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/bug-6732.php @@ -0,0 +1,40 @@ + */ + public array $items; + + /** + * @param array $items + */ + public function __construct(array $items) + { + $this->items = $items; + } + +} + +class Foo +{ + + /** @var ArrayCollection $ints */ + public ArrayCollection $ints; + + /** @var ArrayCollection $strings */ + public ArrayCollection $strings; + + public function __construct() { + $array = new ArrayCollection([]); + $this->ints = $array; + $this->strings = $array; + } + +} diff --git a/tests/PHPStan/Type/Generic/UnresolvedTemplateArgumentTypeTest.php b/tests/PHPStan/Type/Generic/UnresolvedTemplateArgumentTypeTest.php new file mode 100644 index 00000000000..9204f1c8f99 --- /dev/null +++ b/tests/PHPStan/Type/Generic/UnresolvedTemplateArgumentTypeTest.php @@ -0,0 +1,146 @@ +assertTrue($marker->equals(self::marker($site, new ConstantIntegerType(2))), 'initial type is ignored'); + $this->assertTrue($marker->equals(self::marker($site, null))); + $this->assertFalse($marker->equals(self::marker($otherSite, new ConstantIntegerType(1)))); + $this->assertFalse($marker->equals(self::marker($site, new ConstantIntegerType(1), 'U'))); + $this->assertFalse($marker->equals(new ConstantIntegerType(1))); + $this->assertFalse((new ConstantIntegerType(1))->equals($marker)); + } + + public function testBehavesAsItsDelegate(): void + { + $marker = self::marker(new Variable('a'), new ConstantIntegerType(1)); + + $this->assertTrue((new IntegerType())->isSuperTypeOf($marker)->yes()); + $this->assertTrue((new IntegerType())->accepts($marker, true)->yes()); + $this->assertTrue($marker->isSuperTypeOf(new ConstantIntegerType(1))->yes()); + $this->assertTrue((new StringType())->isSuperTypeOf($marker)->no()); + $this->assertTrue($marker->isInteger()->yes()); + $this->assertSame([1], $marker->getConstantScalarValues()); + + $unresolvable = self::marker(new Variable('a'), null); + $this->assertInstanceOf(MixedType::class, $unresolvable->getDelegate()); + $this->assertTrue($unresolvable->isObject()->maybe()); + } + + public function testInvariantPositionIsOpaqueCovariantIsTransparent(): void + { + $marker = self::marker(new Variable('a'), new IntegerType()); + $ofInt = new GenericObjectType(A\A::class, [new IntegerType()]); + $ofMarker = new GenericObjectType(A\A::class, [$marker]); + + $this->assertTrue($ofInt->isSuperTypeOf(new GenericObjectType(A\A::class, [new IntegerType()]))->yes()); + $this->assertTrue($ofInt->isSuperTypeOf($ofMarker)->no(), 'invariant positions compare with equals(), the marker never equals its delegate'); + $this->assertTrue($ofInt->accepts($ofMarker, true)->no()); + + $ofCovariantInt = new GenericObjectType(A\A::class, [new IntegerType()], variances: [TemplateTypeVariance::createCovariant()]); + $this->assertTrue($ofCovariantInt->isSuperTypeOf($ofMarker)->yes(), 'call-site covariant positions accept the delegate'); + } + + public function testUnionKeepsMarkersOfDifferentSites(): void + { + $site = new Variable('a'); + $ofMarker = new GenericObjectType(A\A::class, [self::marker($site, new ConstantIntegerType(1))]); + $ofInt = new GenericObjectType(A\A::class, [new IntegerType()]); + + $union = TypeCombinator::union($ofMarker, $ofInt); + $this->assertInstanceOf(UnionType::class, $union); + $this->assertCount(2, $union->getTypes()); + + $sameSite = TypeCombinator::union($ofMarker, new GenericObjectType(A\A::class, [self::marker($site, new ConstantIntegerType(2))])); + $this->assertInstanceOf(GenericObjectType::class, $sameSite); + + $nullable = TypeCombinator::union($ofMarker, new NullType()); + $this->assertSame('PHPStan\Type\Test\A\A|null', $nullable->describe(VerbosityLevel::precise())); + + $otherSite = new GenericObjectType(A\A::class, [self::marker(new Variable('b'), new ConstantIntegerType(1))]); + $twoSites = TypeCombinator::union($ofMarker, $otherSite); + $this->assertInstanceOf(UnionType::class, $twoSites); + $this->assertCount(2, $twoSites->getTypes()); + } + + public function testDescribe(): void + { + $a = self::marker(new Variable('a'), new ConstantIntegerType(1)); + $b = self::marker(new Variable('b'), new ConstantIntegerType(1)); + + $this->assertSame('unresolved(1)', $a->describe(VerbosityLevel::value())); + $this->assertSame('unresolved(int)', $a->describe(VerbosityLevel::typeOnly())); + $this->assertStringStartsWith('unresolved#', $a->describe(VerbosityLevel::cache())); + $this->assertNotSame($a->describe(VerbosityLevel::cache()), $b->describe(VerbosityLevel::cache()), 'cache descriptions are unique per site'); + $this->assertSame('unresolved(mixed)', self::marker(new Variable('a'), null)->describe(VerbosityLevel::precise())); + } + + public function testTraverseAndGeneralizeKeepTheSite(): void + { + $site = new Variable('a'); + $outer = TemplateTypeFactory::create(TemplateTypeScope::createWithFunction('f'), 'TOuter', new MixedType(), TemplateTypeVariance::createInvariant()); + $marker = self::marker($site, $outer); + + $this->assertTrue($marker->hasTemplateOrLateResolvableType()); + $resolved = TypeTraverser::map($marker, static function (Type $type, callable $traverse) use ($outer): Type { + if ($type === $outer) { + return new IntegerType(); + } + + return $traverse($type); + }); + $this->assertInstanceOf(UnresolvedTemplateArgumentType::class, $resolved); + $this->assertSame($site, $resolved->getSite()); + $this->assertInstanceOf(IntegerType::class, $resolved->getInitialType()); + $this->assertTrue($resolved->equals($marker)); + + $generalized = self::marker($site, new ConstantIntegerType(1))->generalize(GeneralizePrecision::lessSpecific()); + $this->assertInstanceOf(UnresolvedTemplateArgumentType::class, $generalized); + $this->assertSame('unresolved(int)', $generalized->describe(VerbosityLevel::precise())); + + $unresolvable = self::marker($site, null); + $this->assertSame($unresolvable, $unresolvable->generalize(GeneralizePrecision::lessSpecific())); + } + +} From 2edf14d355ad72a45657d7f140ed36ddd8d4906f Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 1 Sep 2026 19:04:19 +0200 Subject: [PATCH 02/28] Add regression tests for issues fixed by unresolved template arguments Each playground sample, verbatim under its own namespace, analyses clean with the feature on and reports the original false positive with the toggle off. Closes https://github.com/phpstan/phpstan/issues/12704 Closes https://github.com/phpstan/phpstan/issues/12576 Closes https://github.com/phpstan/phpstan/issues/10419 Closes https://github.com/phpstan/phpstan/issues/14647 Closes https://github.com/phpstan/phpstan/issues/13431 Closes https://github.com/phpstan/phpstan/issues/12601 Closes https://github.com/phpstan/phpstan/issues/12490 Closes https://github.com/phpstan/phpstan/issues/12420 Closes https://github.com/phpstan/phpstan/issues/11835 Closes https://github.com/phpstan/phpstan/issues/11435 Closes https://github.com/phpstan/phpstan/issues/10290 Closes https://github.com/phpstan/phpstan/issues/10289 Closes https://github.com/phpstan/phpstan/issues/5741 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GNLiox4nj8c7YstGfsuZ39 --- .../Rules/Functions/ReturnTypeRuleTest.php | 22 ++++ .../Rules/Functions/data/bug-10290.php | 65 +++++++++++ .../Rules/Functions/data/bug-10419.php | 32 ++++++ .../PHPStan/Rules/Functions/data/bug-5741.php | 57 ++++++++++ .../Rules/Methods/ReturnTypeRuleTest.php | 58 ++++++++++ .../PHPStan/Rules/Methods/data/bug-10289.php | 21 ++++ .../PHPStan/Rules/Methods/data/bug-11435.php | 30 +++++ .../PHPStan/Rules/Methods/data/bug-11835.php | 42 +++++++ .../PHPStan/Rules/Methods/data/bug-12420.php | 85 ++++++++++++++ .../PHPStan/Rules/Methods/data/bug-12490.php | 106 ++++++++++++++++++ .../PHPStan/Rules/Methods/data/bug-12576.php | 38 +++++++ .../PHPStan/Rules/Methods/data/bug-12601.php | 22 ++++ .../PHPStan/Rules/Methods/data/bug-12704.php | 29 +++++ .../PHPStan/Rules/Methods/data/bug-14647.php | 47 ++++++++ .../TypesAssignedToPropertiesRuleTest.php | 8 ++ .../Rules/Properties/data/bug-13431.php | 24 ++++ 16 files changed, 686 insertions(+) create mode 100644 tests/PHPStan/Rules/Functions/data/bug-10290.php create mode 100644 tests/PHPStan/Rules/Functions/data/bug-10419.php create mode 100644 tests/PHPStan/Rules/Functions/data/bug-5741.php create mode 100644 tests/PHPStan/Rules/Methods/data/bug-10289.php create mode 100644 tests/PHPStan/Rules/Methods/data/bug-11435.php create mode 100644 tests/PHPStan/Rules/Methods/data/bug-11835.php create mode 100644 tests/PHPStan/Rules/Methods/data/bug-12420.php create mode 100644 tests/PHPStan/Rules/Methods/data/bug-12490.php create mode 100644 tests/PHPStan/Rules/Methods/data/bug-12576.php create mode 100644 tests/PHPStan/Rules/Methods/data/bug-12601.php create mode 100644 tests/PHPStan/Rules/Methods/data/bug-12704.php create mode 100644 tests/PHPStan/Rules/Methods/data/bug-14647.php create mode 100644 tests/PHPStan/Rules/Properties/data/bug-13431.php diff --git a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php index 8167add9c7c..bc64a86715c 100644 --- a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php @@ -493,4 +493,26 @@ public function testBug13114(): void ]); } + public function testBug10419(): void + { + $this->checkNullables = true; + $this->checkExplicitMixed = true; + $this->analyse([__DIR__ . '/data/bug-10419.php'], []); + } + + #[RequiresPhp('>= 8.2.0')] + public function testBug10290(): void + { + $this->checkNullables = true; + $this->checkExplicitMixed = true; + $this->analyse([__DIR__ . '/data/bug-10290.php'], []); + } + + public function testBug5741(): void + { + $this->checkNullables = true; + $this->checkExplicitMixed = false; + $this->analyse([__DIR__ . '/data/bug-5741.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Functions/data/bug-10290.php b/tests/PHPStan/Rules/Functions/data/bug-10290.php new file mode 100644 index 00000000000..584c65b15cf --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-10290.php @@ -0,0 +1,65 @@ += 8.2 + +declare(strict_types = 1); + +namespace Bug10290; + +abstract class Result{ + /** + * @template T + * @param Ok $ok + * @return T + */ + public static function getOk(Ok $ok) { return $ok->data; } + + /** + * @template E + * @param Err $err + * @return E + */ + public static function getErr(Err $err) { return $err->data; } +} +/** @template T */ +final readonly class Ok extends Result { + /** @param T $data */ + public function __construct(protected mixed $data) {} +} +/** @template E */ +final readonly class Err extends Result { + /** @param E $data */ + public function __construct(protected mixed $data) {} +} + +/** + * @return Ok|Err> + */ +function f(string $json): Result +{ + $data = json_decode($json, true, JSON_THROW_ON_ERROR); + assert(is_array($data)); + + if (isset($data['has_error']) && $data['has_error']) { + \PHPStan\dumpType($data); + return new Err($data); + } + + $email = filter_var($data['email'], FILTER_VALIDATE_EMAIL); + if ($email === false) { + \PHPStan\dumpType($data); + return new Err($data); + } + + return new Ok($email); +} + +/** + * @return Ok|Err + */ +function g(): Result +{ + if (rand() === 1) { + return new Ok(true); + } + + return new Err('error'); +} diff --git a/tests/PHPStan/Rules/Functions/data/bug-10419.php b/tests/PHPStan/Rules/Functions/data/bug-10419.php new file mode 100644 index 00000000000..cb7ce63a7b2 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-10419.php @@ -0,0 +1,32 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug10419; + +use DateTime; + +/** + * @template T + */ +class Foo { + /** + * @param T $value + */ + public function __construct(public $value) {} +} + +/** + * @return Foo> + */ +function fail(): Foo { + return new Foo([ + (new DateTime)->format('Y-m-d') => [ + 'boolKey' => false, + 'naturalKey' => 0, + ], + ]); +} diff --git a/tests/PHPStan/Rules/Functions/data/bug-5741.php b/tests/PHPStan/Rules/Functions/data/bug-5741.php new file mode 100644 index 00000000000..94c9d91ca9c --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-5741.php @@ -0,0 +1,57 @@ +value = $value; + } +} + + +/** + * @return Result + */ +function one() +{ + $ints = [1, 2]; + + \PHPStan\dumpType($ints); + return new Result($ints); +} + + +/** + * @return Result + */ +function two() +{ + $result = new Result([]); + $result->value = [1]; + $result->value[] = 2; + + \PHPStan\dumpType($result->value); + return $result; +} + + +/** + * @return int[] + */ +function three() +{ + $ints = [1, 2]; + \PHPStan\dumpType($ints); + return $ints; +} diff --git a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php index b84c976fec9..57094ba6f31 100644 --- a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php @@ -1344,4 +1344,62 @@ public function testBug14893(): void $this->analyse([__DIR__ . '/data/bug-14893.php'], []); } + public function testBug12704(): void + { + $this->checkExplicitMixed = true; + $this->analyse([__DIR__ . '/data/bug-12704.php'], []); + } + + public function testBug12576(): void + { + $this->checkExplicitMixed = true; + $this->analyse([__DIR__ . '/data/bug-12576.php'], []); + } + + #[RequiresPhp('>= 8.1.0')] + public function testBug14647(): void + { + $this->checkExplicitMixed = true; + $this->analyse([__DIR__ . '/data/bug-14647.php'], []); + } + + public function testBug12601(): void + { + $this->checkExplicitMixed = true; + $this->analyse([__DIR__ . '/data/bug-12601.php'], []); + } + + public function testBug12490(): void + { + $this->analyse([__DIR__ . '/data/bug-12490.php'], []); + } + + #[RequiresPhp('>= 8.1.0')] + public function testBug12420(): void + { + $this->checkExplicitMixed = true; + $this->analyse([__DIR__ . '/data/bug-12420.php'], [ + [ + 'Method Bug12420\Test::testFailingArray() should return array<\'bar\'|\'foo\'> but returns array{\'foo\', \'bar\', \'wrong\'}.', + 66, + ], + ]); + } + + public function testBug11835(): void + { + $this->checkExplicitMixed = true; + $this->analyse([__DIR__ . '/data/bug-11835.php'], []); + } + + public function testBug11435(): void + { + $this->analyse([__DIR__ . '/data/bug-11435.php'], []); + } + + public function testBug10289(): void + { + $this->analyse([__DIR__ . '/data/bug-10289.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/bug-10289.php b/tests/PHPStan/Rules/Methods/data/bug-10289.php new file mode 100644 index 00000000000..bcb07b217fc --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-10289.php @@ -0,0 +1,21 @@ + + */ +class X implements IteratorAggregate +{ + /** @var array */ + private array $data = ['x' => 'y']; + + /** @return ArrayIterator */ + public function getIterator(): ArrayIterator + { + return new ArrayIterator($this->data); + } +} diff --git a/tests/PHPStan/Rules/Methods/data/bug-11435.php b/tests/PHPStan/Rules/Methods/data/bug-11435.php new file mode 100644 index 00000000000..bb446925cfe --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-11435.php @@ -0,0 +1,30 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug11435; + +/** + * @implements \IteratorAggregate + */ +class Example implements \IteratorAggregate +{ + /** + * @param list $elements + */ + public function __construct( + private array $elements, + ) { + } + + public function getIterator(): \Traversable + { + return new \ArrayIterator($this->elements); + } +} diff --git a/tests/PHPStan/Rules/Methods/data/bug-11835.php b/tests/PHPStan/Rules/Methods/data/bug-11835.php new file mode 100644 index 00000000000..a996d77f68d --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-11835.php @@ -0,0 +1,42 @@ + */ + public function chunk(int $size): self + { + return $this; + } + + /** + * @template TMapValue + * + * @param callable(TValue, TKey): TMapValue $callback + * @return self + */ + public function map(callable $callback): self + { + return $this; + } +} + +class DateHeader {} + +class ProjectionCumulativeHeadersResolver +{ + /** + * @param Collection $projectionMonthsHeaders + * @return Collection + */ + public function resolve(Collection $projectionMonthsHeaders): Collection + { + return $projectionMonthsHeaders->chunk(2) + ->map(fn ($_, $index) => $index * 2 + 2 . ' month'); + } +} diff --git a/tests/PHPStan/Rules/Methods/data/bug-12420.php b/tests/PHPStan/Rules/Methods/data/bug-12420.php new file mode 100644 index 00000000000..407a0d1eb25 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-12420.php @@ -0,0 +1,85 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug12420; + +/** + * @template V + */ +class Collection +{ + /** + * @var array + */ + private array $array; + + /** + * @param array $a + */ + final public function __construct(array $a = []) + { + $this->array = $a; + } + + /** + * @return array + */ + public function toArray(): array + { + return $this->array; + } + + // ... +} + +enum Code: string +{ + case FOO = 'foo'; + case BAR = 'bar'; +} + + +class Test +{ + + /** + * This works. + * + * @return array> + */ + public static function testArray(): array + { + return [ + Code::FOO->value, + Code::BAR->value, + ]; + } + + /** + * This fails as expectd. + * + * @return array> + */ + public static function testFailingArray(): array + { + return [ + Code::FOO->value, + Code::BAR->value, + 'wrong', + ]; + } + + /** + * FIXME This fails because the type infered from the constructor call is `Collection`. + * + * @return Collection> + */ + public static function testCollection(): Collection + { + return new Collection([ + Code::FOO->value, + Code::BAR->value, + ]); + } +} diff --git a/tests/PHPStan/Rules/Methods/data/bug-12490.php b/tests/PHPStan/Rules/Methods/data/bug-12490.php new file mode 100644 index 00000000000..763b7de8f55 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-12490.php @@ -0,0 +1,106 @@ +): TGet)|null */ + public $get; + + /** @var (callable(TSet, array): mixed)|null*/ + public $set; + + /** + * Create a new attribute accessor / mutator. + * + * @param (callable(mixed, array): TGet)|null $get + * @param (callable(TSet, array): mixed)|null $set + */ + public function __construct(?callable $get = null, ?callable $set = null) + { + $this->get = $get; + $this->set = $set; + } + + /** + * @template TMakeGet + * @template TMakeSet + * @param (callable(mixed, array): TMakeGet)|null $get + * @param (callable(TMakeSet, array): mixed)|null $set + * @return Attribute + */ + public static function make(?callable $get = null, ?callable $set = null): self + { + return new self($get, $set); + } + + /** + * @template T + * @param callable(mixed, array): T $get + * @return Attribute + */ + public static function get(callable $get): self + { + return new self($get); + } + + /** + * @template T + * @param callable(T, array): mixed $set + * @return Attribute + */ + public static function set(callable $set): self + { + return new self(null, $set); + } +} + + +class Foo +{ + public ?int $id = null; + public ?string $surveyable_type = null; + + /** @return Attribute */ + protected function uri(): Attribute + { + return Attribute::get(fn (): string => "fOo/{$this->id}"); + } + + /** @return Attribute */ + protected function uri2(): Attribute + { + return Attribute::get(fn (): string => "foo/{$this->id}"); + } + + /** + * @return Attribute + */ + protected function surveyedLink(): Attribute + { + return Attribute::get(fn () => $this->surveyable_type); + } + + + /** @return Attribute */ + protected function packageWeightCalculated(): Attribute + { + return Attribute::get(fn () => $this->id === null ? null : round(50 * .15, 2)); + } + + + /** @return Attribute */ + protected function durationMs(): Attribute + { + return Attribute::get(fn () => $this->id); + } +} diff --git a/tests/PHPStan/Rules/Methods/data/bug-12576.php b/tests/PHPStan/Rules/Methods/data/bug-12576.php new file mode 100644 index 00000000000..a4aefcec574 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-12576.php @@ -0,0 +1,38 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug12576; + +/** + * @template TKey of array-key + * @template TValue + */ +class Collection +{ + final public function __construct( + /** @var array */ + protected array $items = [], + ) {} + + /** @return static, TValue> */ + public function values(): static + { + return new static(array_values($this->items)); + } + + /** @return array */ + public function all(): array + { + return $this->items; + } +} + +/** + * @param Collection $foo + * @return list + */ +function test(Collection $foo): array +{ + return $foo->values()->all(); +} diff --git a/tests/PHPStan/Rules/Methods/data/bug-12601.php b/tests/PHPStan/Rules/Methods/data/bug-12601.php new file mode 100644 index 00000000000..22117d1714c --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-12601.php @@ -0,0 +1,22 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug12601; + +use ArrayIterator; +use IteratorAggregate; +use Traversable; + +/** @implements IteratorAggregate */ +class HelloWorld implements IteratorAggregate +{ + /** @param array $map */ + public function __construct(private array $map) {} + + /** @return Traversable */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->map); + } +} diff --git a/tests/PHPStan/Rules/Methods/data/bug-12704.php b/tests/PHPStan/Rules/Methods/data/bug-12704.php new file mode 100644 index 00000000000..dad5be4f49c --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-12704.php @@ -0,0 +1,29 @@ + + */ + public function baz() + { + return new static; + } +} + +/** + * @template TValue + */ +final class Bar { + /** + * @return self + */ + public function baz() + { + return new self; + } +} diff --git a/tests/PHPStan/Rules/Methods/data/bug-14647.php b/tests/PHPStan/Rules/Methods/data/bug-14647.php new file mode 100644 index 00000000000..ca634ea0ecf --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-14647.php @@ -0,0 +1,47 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug14647; + +/** + * @template TValue + */ +class Collection +{ + /** @param array $items */ + public function __construct(private readonly array $items) {} + + /** @return array */ + public function items(): array + { + return $this->items; + } +} + +abstract class AbstractValue +{ + final public function __construct() {} + + /** @return Collection */ + public function collect(): Collection + { + return new Collection([new static()]); + } +} + +final class Value extends AbstractValue +{ + /** @return Collection */ + #[\Override] + public function collect(): Collection + { + return parent::collect(); + } + + /** @return Collection */ + public function childCollect(): Collection + { + return new Collection([new static()]); + } +} diff --git a/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php b/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php index 54b79577624..079a36482a2 100644 --- a/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php +++ b/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php @@ -1105,4 +1105,12 @@ public function testBug6732(): void ]); } + #[RequiresPhp('>= 8.2.0')] + public function testBug13431(): void + { + $this->checkExplicitMixed = true; + $this->checkImplicitMixed = true; + $this->analyse([__DIR__ . '/data/bug-13431.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Properties/data/bug-13431.php b/tests/PHPStan/Rules/Properties/data/bug-13431.php new file mode 100644 index 00000000000..47c59b7e3be --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/bug-13431.php @@ -0,0 +1,24 @@ += 8.2 + +declare(strict_types = 1); + +namespace Bug13431; + +use Ds\Set; + +readonly class ShortStepWithElements +{ + /** + * @var Set + */ + public Set $elementHashes; + + /** + * @param Set $elements + */ + public function __construct(public Set $elements) { + $this->elementHashes = $this->elements->map( + fn (string $element): string => hash('sha256', $element), + ); + } +} From 168c594a866c70a443456e86c8d36c3bb593f5b4 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 1 Sep 2026 20:30:52 +0200 Subject: [PATCH 03/28] Add regression test for #8031 A constant-keyed array argument no longer generalizes the collection's key template to string when the declared return type asks for the literal keys. Closes https://github.com/phpstan/phpstan/issues/8031 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GNLiox4nj8c7YstGfsuZ39 --- .../Rules/Functions/ReturnTypeRuleTest.php | 7 +++++ .../PHPStan/Rules/Functions/data/bug-8031.php | 28 +++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 tests/PHPStan/Rules/Functions/data/bug-8031.php diff --git a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php index bc64a86715c..511324142f6 100644 --- a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php @@ -515,4 +515,11 @@ public function testBug5741(): void $this->analyse([__DIR__ . '/data/bug-5741.php'], []); } + public function testBug8031(): void + { + $this->checkNullables = true; + $this->checkExplicitMixed = true; + $this->analyse([__DIR__ . '/data/bug-8031.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Functions/data/bug-8031.php b/tests/PHPStan/Rules/Functions/data/bug-8031.php new file mode 100644 index 00000000000..e5205d0d2af --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-8031.php @@ -0,0 +1,28 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug8031; + +/** + * @template TKey of array-key + * @template TValue of mixed + */ +class Collection +{ + /** + * @param array $val + */ + public function __construct(protected array $val) {} +} + +/** + * @return Collection<'one'|'two', int> + */ +function test(): Collection +{ + return new Collection([ + 'one' => 1, + 'two'=> 2 + ]); +} From 20c3947c8611baa238e3d5cf53a2422aea7b059b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 6 Sep 2026 10:21:30 +0200 Subject: [PATCH 04/28] Pin bug-15169-calls to the unresolved template argument semantics `new Generic(1)` keeps the exact argument instead of generalizing it, and the frame's resolution is shared by both flavours, so the native one reads it too. The offset-set observation reads the dim chain's last key, which the dim path always has. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GNLiox4nj8c7YstGfsuZ39 --- src/Analyser/ExprHandler/AssignHandler.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-15169-calls.php | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index e91b5c77c25..43a486f1e4b 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -1368,7 +1368,7 @@ public function applyWrite( $scope = $scope->addTemplateArgumentConstraints($nodeScopeResolver->collectOffsetSetUsage( $scope, $setVarType, - count($offsetTypes) > 0 ? $offsetTypes[count($offsetTypes) - 1][0] : null, + $offsetTypes[count($offsetTypes) - 1][0], $writtenValueType, )); $throwPoints = array_merge($throwPoints, $this->methodThrowPointHelper->getThrowPointsForCallOnType( diff --git a/tests/PHPStan/Analyser/nsrt/bug-15169-calls.php b/tests/PHPStan/Analyser/nsrt/bug-15169-calls.php index d74b93932ba..25be0c92edc 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-15169-calls.php +++ b/tests/PHPStan/Analyser/nsrt/bug-15169-calls.php @@ -95,12 +95,12 @@ function funcCall(C $c): void function instantiation(): void { - // the template argument is inferred from the phpdoc @param, so it stays - // unresolved in the native flavour - assertType('Bug15169Calls\Generic', new Generic(1)); - assertNativeType('Bug15169Calls\Generic', new Generic(1)); + // the template argument is inferred from the phpdoc @param; the frame's + // resolution is shared by both flavours, so the native one sees it too + assertType('Bug15169Calls\Generic<1>', new Generic(1)); + assertNativeType('Bug15169Calls\Generic<1>', new Generic(1)); - assertType('int', (new Generic(1))->value); + assertType('1', (new Generic(1))->value); assertNativeType('mixed', (new Generic(1))->value); } From 8fd2168ea560a14f86d99e450de69e67a2228f38 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 6 Sep 2026 11:36:14 +0200 Subject: [PATCH 05/28] Pin the native flavour of template arguments (currently failing) Natively a template argument is never inferred and never carries what the phpdoc flavour resolved it to: `new Foo(1)` is `Foo` for an unbounded template and `Foo` for `@template T of int`, and a generic call result is the bare native return type. These assertions hold on 2.3.x and fail on this branch, which lets the frame's resolutions reach the native types - left failing on purpose as the spec for that fix. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GNLiox4nj8c7YstGfsuZ39 --- .../nsrt/native-template-arguments.php | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/native-template-arguments.php diff --git a/tests/PHPStan/Analyser/nsrt/native-template-arguments.php b/tests/PHPStan/Analyser/nsrt/native-template-arguments.php new file mode 100644 index 00000000000..a13374ff40b --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/native-template-arguments.php @@ -0,0 +1,92 @@ + + */ + public static function make($v): BoundedInt + { + return new BoundedInt($v); + } + +} + +function instantiation(): void +{ + assertNativeType('NativeTemplateArguments\Unbounded', new Unbounded(1)); + assertNativeType('NativeTemplateArguments\BoundedInt', new BoundedInt(1)); + assertNativeType('NativeTemplateArguments\BoundedObject', new BoundedObject(new Unbounded(1))); +} + +function sentToADeclaredType(): void +{ + $unbounded = new Unbounded(1); + takesUnbounded($unbounded); + assertNativeType('NativeTemplateArguments\Unbounded', $unbounded); + + $bounded = new BoundedInt(1); + takesBoundedInt($bounded); + assertNativeType('NativeTemplateArguments\BoundedInt', $bounded); +} + +function callResult(): void +{ + assertNativeType('NativeTemplateArguments\BoundedInt', BoundedIntFactory::make(1)); +} + +/** @param Unbounded $u */ +function takesUnbounded(Unbounded $u): void +{ +} + +/** @param BoundedInt<1> $b */ +function takesBoundedInt(BoundedInt $b): void +{ +} From 5fad23a62cd600af82f13ae7d9a19ff511cf59cf Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 6 Sep 2026 20:48:59 +0200 Subject: [PATCH 06/28] Avoid retaining call ASTs in template argument caches Keep the call expression as a weak cache key, alongside the weak inference-context key. Cache identity remains exact without extending either object lifetime. Validation: the cache lifetime assertion fails with the strong expression key and passes with weak keys. --- src/Reflection/ResolvedFunctionVariantWithOriginal.php | 10 +++++++--- .../Analyser/Generics/TemplateArgumentResolverTest.php | 10 ++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/Reflection/ResolvedFunctionVariantWithOriginal.php b/src/Reflection/ResolvedFunctionVariantWithOriginal.php index 20af88946fc..f3ad411f46a 100644 --- a/src/Reflection/ResolvedFunctionVariantWithOriginal.php +++ b/src/Reflection/ResolvedFunctionVariantWithOriginal.php @@ -37,7 +37,11 @@ final class ResolvedFunctionVariantWithOriginal implements ResolvedFunctionVaria private ?Type $phpDocReturnType = null; - /** @var array{Expr, WeakReference, bool, Type}|null */ + /** + * Cache keys must not keep the call AST or inference context alive. + * + * @var array{WeakReference, WeakReference, bool, Type}|null + */ private ?array $returnTypeWithUnresolvedTemplateArguments = null; /** @@ -186,7 +190,7 @@ public function getReturnType(): Type public function getReturnTypeWithUnresolvedTemplateArguments(Expr $site, TemplateArgumentFrame $frame, bool $allowUnresolved): Type { $cached = $this->returnTypeWithUnresolvedTemplateArguments; - if ($cached !== null && $cached[0] === $site && $cached[1]->get() === $frame && $cached[2] === $allowUnresolved) { + if ($cached !== null && $cached[0]->get() === $site && $cached[1]->get() === $frame && $cached[2] === $allowUnresolved) { return $cached[3]; } @@ -201,7 +205,7 @@ public function getReturnTypeWithUnresolvedTemplateArguments(Expr $site, Templat ), false, ); - $this->returnTypeWithUnresolvedTemplateArguments = [$site, WeakReference::create($frame), $allowUnresolved, $type]; + $this->returnTypeWithUnresolvedTemplateArguments = [WeakReference::create($site), WeakReference::create($frame), $allowUnresolved, $type]; return $type; } diff --git a/tests/PHPStan/Analyser/Generics/TemplateArgumentResolverTest.php b/tests/PHPStan/Analyser/Generics/TemplateArgumentResolverTest.php index abd11020266..ee3db473365 100644 --- a/tests/PHPStan/Analyser/Generics/TemplateArgumentResolverTest.php +++ b/tests/PHPStan/Analyser/Generics/TemplateArgumentResolverTest.php @@ -28,6 +28,7 @@ use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\VerbosityLevel; +use WeakReference; class TemplateArgumentResolverTest extends PHPStanTestCase { @@ -264,6 +265,15 @@ public function testReturnTypeCacheDistinguishesImmutableResolutions(): void $this->assertSame('PHPStan\Type\Test\A\A<1>', self::describe($variant->getReturnTypeWithUnresolvedTemplateArguments($site, $initial, true))); $this->assertTrue($unresolved->equals($variant->getReturnTypeWithUnresolvedTemplateArguments($site, $collecting, true))); $this->assertTrue($collecting->isObserving()); + + $reference = WeakReference::create($collecting); + unset($collecting); + $this->assertNull($reference->get(), 'The return-type cache must not retain the inference context.'); + + $variant->getReturnTypeWithUnresolvedTemplateArguments($site, $sent, true); + $siteReference = WeakReference::create($site); + unset($site, $marker, $constraints, $unresolved); + $this->assertNull($siteReference->get(), 'The resolved return-type cache must not retain the call AST.'); } public function testSiteAttributionByTokenPosition(): void From 3b1a68801274d2495235503975d41790a6c13c40 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 6 Sep 2026 20:49:17 +0200 Subject: [PATCH 07/28] Preserve inference constraints from arrow function arguments Merge the specialized arrow-argument result back into the caller scope. This preserves constraints from captured generic values and nested generic constructors inside callbacks. Validation: focused flow assertions and the nested constructor rule regression, reproduced before the fix. --- src/Analyser/NodeScopeResolver.php | 1 + .../Generics/data/constraint-flow.php | 11 +++++ .../Rules/Classes/InstantiationRuleTest.php | 5 ++ .../Classes/data/template-argument-arrow.php | 47 +++++++++++++++++++ 4 files changed, 64 insertions(+) create mode 100644 tests/PHPStan/Rules/Classes/data/template-argument-arrow.php diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index e5cf1a91b50..f9c7fd043b6 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -3079,6 +3079,7 @@ public function processArgs( $deferredInvalidateExpressions[] = [$arrowFunctionType->getInvalidateExpressions(), $arrowFunctionType->getUsedVariables()]; } } + $scope = $scope->addTemplateArgumentConstraints($argResults[spl_object_id($arg->value)]->getScope()->getTemplateArgumentConstraints()); } else { $enterExpressionAssignForByRef = $assignByReference && $arg->value instanceof ArrayDimFetch && $arg->value->dim === null; if ($enterExpressionAssignForByRef) { diff --git a/tests/PHPStan/Analyser/Generics/data/constraint-flow.php b/tests/PHPStan/Analyser/Generics/data/constraint-flow.php index 8b5a09267a1..b645bcdfb1e 100644 --- a/tests/PHPStan/Analyser/Generics/data/constraint-flow.php +++ b/tests/PHPStan/Analyser/Generics/data/constraint-flow.php @@ -77,3 +77,14 @@ function shortCircuit(bool $condition): void $condition && consume($box); assertType('TemplateArgumentConstraintFlow\Box', $box); } + +function acceptCallback(callable $callback): void +{ +} + +function arrowArgument(): void +{ + $box = new Box(); + acceptCallback(fn () => consume($box)); + assertType('TemplateArgumentConstraintFlow\Box', $box); +} diff --git a/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php b/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php index 1ef99a5f51a..2d1734cb0ac 100644 --- a/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php +++ b/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php @@ -71,6 +71,11 @@ protected function getRule(): Rule ); } + public function testTemplateArgumentArrow(): void + { + $this->analyse([__DIR__ . '/data/template-argument-arrow.php'], []); + } + public function testInstantiation(): void { $this->analyse( diff --git a/tests/PHPStan/Rules/Classes/data/template-argument-arrow.php b/tests/PHPStan/Rules/Classes/data/template-argument-arrow.php new file mode 100644 index 00000000000..35705cc59f6 --- /dev/null +++ b/tests/PHPStan/Rules/Classes/data/template-argument-arrow.php @@ -0,0 +1,47 @@ + + */ +class ArrayCollection implements ReadableCollection +{ + /** @param array $values */ + public function __construct(array $values) + { + } +} + +/** @template TValue */ +class ReadOnlyCollection +{ + /** @param ReadableCollection $collection */ + public function __construct(ReadableCollection $collection) + { + } +} + +function acceptCallback(callable $callback): void +{ +} + +/** @param list $addresses */ +function callback(array $addresses): void +{ + acceptCallback(static fn (string $method) => match ($method) { + 'getAddresses' => new ReadOnlyCollection(new ArrayCollection($addresses)), + 'getDirectAddresses' => new ReadOnlyCollection(new ArrayCollection([])), + default => null, + }); +} From a87e6be070515566f596ee8db9fc95f58f8af0c2 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 6 Sep 2026 20:49:31 +0200 Subject: [PATCH 08/28] Preserve inference constraints from terminating expression branches Retain inference facts from boolean, coalescing, ternary, and match branches even when their variable state cannot continue. Include adjacent switch-termination coverage. Validation: six previously failing terminating-expression assertions now pass. --- .../ExprHandler/BooleanAndHandler.php | 2 +- src/Analyser/ExprHandler/BooleanOrHandler.php | 2 +- src/Analyser/ExprHandler/CoalesceHandler.php | 2 +- src/Analyser/ExprHandler/MatchHandler.php | 5 ++ src/Analyser/ExprHandler/TernaryHandler.php | 3 ++ .../Generics/data/constraint-flow.php | 48 +++++++++++++++++++ 6 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/Analyser/ExprHandler/BooleanAndHandler.php b/src/Analyser/ExprHandler/BooleanAndHandler.php index 0e52482947d..3953731021a 100644 --- a/src/Analyser/ExprHandler/BooleanAndHandler.php +++ b/src/Analyser/ExprHandler/BooleanAndHandler.php @@ -50,7 +50,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $rightResult = $nodeScopeResolver->processExprNode($stmt, $expr->right, $leftTruthyScope, $storage, $nodeCallback, $context); $rightExprType = $rightResult->getType(); if ($rightExprType instanceof NeverType && $rightExprType->isExplicit()) { - $leftMergedWithRightScope = $leftResult->getFalseyScope(); + $leftMergedWithRightScope = $leftResult->getFalseyScope()->addTemplateArgumentConstraints($rightResult->getScope()->getTemplateArgumentConstraints()); } else { $leftMergedWithRightScope = $leftResult->getScope()->mergeWith($rightResult->getScope()); } diff --git a/src/Analyser/ExprHandler/BooleanOrHandler.php b/src/Analyser/ExprHandler/BooleanOrHandler.php index a2ea297c5ab..d1ee34e2d44 100644 --- a/src/Analyser/ExprHandler/BooleanOrHandler.php +++ b/src/Analyser/ExprHandler/BooleanOrHandler.php @@ -68,7 +68,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $rightResult = $nodeScopeResolver->processExprNode($stmt, $expr->right, $leftFalseyScope, $storage, $nodeCallback, $context); $rightExprType = $rightResult->getType(); if ($rightExprType instanceof NeverType && $rightExprType->isExplicit()) { - $leftMergedWithRightScope = $leftResult->getTruthyScope(); + $leftMergedWithRightScope = $leftResult->getTruthyScope()->addTemplateArgumentConstraints($rightResult->getScope()->getTemplateArgumentConstraints()); } else { $leftMergedWithRightScope = $leftResult->getScope()->mergeWith($rightResult->getScope()); } diff --git a/src/Analyser/ExprHandler/CoalesceHandler.php b/src/Analyser/ExprHandler/CoalesceHandler.php index 6cef6945c8b..24c3c0c65f3 100644 --- a/src/Analyser/ExprHandler/CoalesceHandler.php +++ b/src/Analyser/ExprHandler/CoalesceHandler.php @@ -74,7 +74,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $rightExprType = $rightResult->getType(); if ($rightExprType instanceof NeverType && $rightExprType->isExplicit()) { - $scope = $scope->applySpecifiedTypes($leftIssetTypes); + $scope = $scope->applySpecifiedTypes($leftIssetTypes)->addTemplateArgumentConstraints($rightResult->getScope()->getTemplateArgumentConstraints()); } else { $scope = $scope->applySpecifiedTypes($leftIssetTypes)->mergeWith($rightResult->getScope()); } diff --git a/src/Analyser/ExprHandler/MatchHandler.php b/src/Analyser/ExprHandler/MatchHandler.php index c0fe5a517bf..4420db342cf 100644 --- a/src/Analyser/ExprHandler/MatchHandler.php +++ b/src/Analyser/ExprHandler/MatchHandler.php @@ -284,6 +284,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ExpressionContext::createTopLevel(), ); $armScope = $armResult->getScope(); + $scope = $scope->addTemplateArgumentConstraints($armScope->getTemplateArgumentConstraints()); if (!$armResult->isAlwaysTerminating()) { $armBodyScopes[] = $armScope; } @@ -322,6 +323,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $armNodes[$i] = new MatchExpressionArm($matchArmBody, [], $arm->getStartLine()); $armResult = $nodeScopeResolver->processExprNode($stmt, $arm->body, $matchScope, $storage, $nodeCallback, ExpressionContext::createTopLevel()); $matchScope = $armResult->getScope(); + $scope = $scope->addTemplateArgumentConstraints($matchScope->getTemplateArgumentConstraints()); $hasYield = $hasYield || $armResult->hasYield(); $throwPoints = array_merge($throwPoints, $armResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $armResult->getImpurePoints()); @@ -430,6 +432,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ExpressionContext::createTopLevel(), ); $armScope = $armResult->getScope(); + $scope = $scope->addTemplateArgumentConstraints($armScope->getTemplateArgumentConstraints()); if (!$armResult->isAlwaysTerminating()) { $armBodyScopes[] = $armScope; } @@ -484,6 +487,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints[] = InternalThrowPoint::createExplicit($scope, new ObjectType(UnhandledMatchError::class), $expr, false); } + $scope = $scope->addTemplateArgumentConstraints($scopeForMatchNodeCallback->getTemplateArgumentConstraints()); + ksort($armNodes, SORT_NUMERIC); $nodeScopeResolver->callNodeCallback($nodeCallback, new MatchExpressionNode($expr->cond, array_values($armNodes), $expr, $matchScope), $scopeForMatchNodeCallback, $storage); diff --git a/src/Analyser/ExprHandler/TernaryHandler.php b/src/Analyser/ExprHandler/TernaryHandler.php index feca03e3fe7..86af979685a 100644 --- a/src/Analyser/ExprHandler/TernaryHandler.php +++ b/src/Analyser/ExprHandler/TernaryHandler.php @@ -136,6 +136,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } } + $finalScope = $finalScope->addTemplateArgumentConstraints($ifTrueScope->getTemplateArgumentConstraints()) + ->addTemplateArgumentConstraints($ifFalseScope->getTemplateArgumentConstraints()); + // lazily memoized merged-falsey scope of the (cond && if) disjunct $aFalseyScope = null; diff --git a/tests/PHPStan/Analyser/Generics/data/constraint-flow.php b/tests/PHPStan/Analyser/Generics/data/constraint-flow.php index b645bcdfb1e..fb874c57e30 100644 --- a/tests/PHPStan/Analyser/Generics/data/constraint-flow.php +++ b/tests/PHPStan/Analyser/Generics/data/constraint-flow.php @@ -88,3 +88,51 @@ function arrowArgument(): void acceptCallback(fn () => consume($box)); assertType('TemplateArgumentConstraintFlow\Box', $box); } + +/** @param Box $box */ +function terminate(Box $box): never +{ + exit; +} + +function terminatingExpressions(bool $condition, ?bool $nullable): void +{ + $and = new Box(); + $condition && terminate($and); + assertType('TemplateArgumentConstraintFlow\Box', $and); + + $or = new Box(); + $condition || terminate($or); + assertType('TemplateArgumentConstraintFlow\Box', $or); + + $coalesce = new Box(); + $nullable ?? terminate($coalesce); + assertType('TemplateArgumentConstraintFlow\Box', $coalesce); + + $ternaryIf = new Box(); + $condition ? terminate($ternaryIf) : false; + assertType('TemplateArgumentConstraintFlow\Box', $ternaryIf); + + $ternaryElse = new Box(); + $condition ? true : terminate($ternaryElse); + assertType('TemplateArgumentConstraintFlow\Box', $ternaryElse); + + $match = new Box(); + match ($condition) { + true => terminate($match), + false => false, + }; + assertType('TemplateArgumentConstraintFlow\Box', $match); +} + +function switchTermination(bool $condition): void +{ + $box = new Box(); + switch ($condition) { + case true: + terminate($box); + default: + break; + } + assertType('TemplateArgumentConstraintFlow\Box', $box); +} From 1f5f1e0bbd977067d31c4f0f43e35a22cd086a19 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 6 Sep 2026 20:49:48 +0200 Subject: [PATCH 09/28] Preserve inference constraints when foreach scope does not escape Carry body constraints into the foreach result independently of variable-state pollution. This keeps generic sites resolvable when polluteScopeWithAlwaysIterableForeach is disabled. Validation: the no-pollution regression fails before this change and passes after it. --- src/Analyser/StmtHandler/ForeachHandler.php | 2 +- ...mplateArgumentFlowWithoutPollutionTest.php | 26 +++++++++++++++++++ .../Generics/data/constraint-flow.php | 9 +++++++ .../Generics/without-foreach-pollution.neon | 2 ++ 4 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Analyser/Generics/TemplateArgumentFlowWithoutPollutionTest.php create mode 100644 tests/PHPStan/Analyser/Generics/without-foreach-pollution.neon diff --git a/src/Analyser/StmtHandler/ForeachHandler.php b/src/Analyser/StmtHandler/ForeachHandler.php index d80e4dccf1a..7fa71fd729c 100644 --- a/src/Analyser/StmtHandler/ForeachHandler.php +++ b/src/Analyser/StmtHandler/ForeachHandler.php @@ -469,7 +469,7 @@ static function () use ($condResult, $emptyArrayType): Type { } return new InternalStatementResult( - $finalScope, + $finalScope->addTemplateArgumentConstraints($finalScopeResult->getScope()->getTemplateArgumentConstraints()), hasYield: $finalScopeResult->hasYield() || $condResult->hasYield(), isAlwaysTerminating: $isIterableAtLeastOnce->yes() && $finalScopeResult->isAlwaysTerminating(), exitPoints: $finalScopeResult->getExitPointsForOuterLoop(), diff --git a/tests/PHPStan/Analyser/Generics/TemplateArgumentFlowWithoutPollutionTest.php b/tests/PHPStan/Analyser/Generics/TemplateArgumentFlowWithoutPollutionTest.php new file mode 100644 index 00000000000..eb03329f7f2 --- /dev/null +++ b/tests/PHPStan/Analyser/Generics/TemplateArgumentFlowWithoutPollutionTest.php @@ -0,0 +1,26 @@ +assertFileAsserts(...$args); + } + } + + public static function getAdditionalConfigFiles(): array + { + return array_merge(parent::getAdditionalConfigFiles(), [ + __DIR__ . '/../../../../conf/bleedingEdge.neon', + __DIR__ . '/without-foreach-pollution.neon', + ]); + } + +} diff --git a/tests/PHPStan/Analyser/Generics/data/constraint-flow.php b/tests/PHPStan/Analyser/Generics/data/constraint-flow.php index fb874c57e30..3208ffaa9ad 100644 --- a/tests/PHPStan/Analyser/Generics/data/constraint-flow.php +++ b/tests/PHPStan/Analyser/Generics/data/constraint-flow.php @@ -125,6 +125,15 @@ function terminatingExpressions(bool $condition, ?bool $nullable): void assertType('TemplateArgumentConstraintFlow\Box', $match); } +function foreachLoop(): void +{ + foreach ([30, 7] as $day) { + $box = new Box(); + consume($box); + assertType('TemplateArgumentConstraintFlow\Box', $box); + } +} + function switchTermination(bool $condition): void { $box = new Box(); diff --git a/tests/PHPStan/Analyser/Generics/without-foreach-pollution.neon b/tests/PHPStan/Analyser/Generics/without-foreach-pollution.neon new file mode 100644 index 00000000000..3ee516d3be6 --- /dev/null +++ b/tests/PHPStan/Analyser/Generics/without-foreach-pollution.neon @@ -0,0 +1,2 @@ +parameters: + polluteScopeWithAlwaysIterableForeach: false From fba526372e4f56a7e06b1ff6d6f6a96b8fca0e0d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 6 Sep 2026 20:50:06 +0200 Subject: [PATCH 10/28] Preserve inference constraints from unreachable for loop bodies Carry loop constraints into the for statement result even when the loop does not contribute continuing variable state. Cover unreachable while and foreach bodies alongside the for regression. Validation: focused inference tests pass; the for assertion reproduced the missing constraint before the fix. --- src/Analyser/StmtHandler/ForHandler.php | 2 +- .../Generics/data/constraint-flow.php | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/Analyser/StmtHandler/ForHandler.php b/src/Analyser/StmtHandler/ForHandler.php index a6dcb16a4c8..630253a8d84 100644 --- a/src/Analyser/StmtHandler/ForHandler.php +++ b/src/Analyser/StmtHandler/ForHandler.php @@ -289,7 +289,7 @@ public function processStmt( } return new InternalStatementResult( - $finalScope, + $finalScope->addTemplateArgumentConstraints($loopScope->getTemplateArgumentConstraints()), hasYield: $finalScopeResult->hasYield() || $hasYield, isAlwaysTerminating: $isAlwaysTerminating, exitPoints: $finalScopeResult->getExitPointsForOuterLoop(), diff --git a/tests/PHPStan/Analyser/Generics/data/constraint-flow.php b/tests/PHPStan/Analyser/Generics/data/constraint-flow.php index 3208ffaa9ad..9afb2997100 100644 --- a/tests/PHPStan/Analyser/Generics/data/constraint-flow.php +++ b/tests/PHPStan/Analyser/Generics/data/constraint-flow.php @@ -134,6 +134,27 @@ function foreachLoop(): void } } +function unreachableLoops(): void +{ + $while = new Box(); + while (false) { + consume($while); + } + assertType('TemplateArgumentConstraintFlow\Box', $while); + + $for = new Box(); + for (; false;) { + consume($for); + } + assertType('TemplateArgumentConstraintFlow\Box', $for); + + $foreach = new Box(); + foreach ([] as $unused) { + consume($foreach); + } + assertType('TemplateArgumentConstraintFlow\Box', $foreach); +} + function switchTermination(bool $condition): void { $box = new Box(); From c359956e7dffbe473f3d958ac4b08a953d578a1f Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 6 Sep 2026 21:28:54 +0200 Subject: [PATCH 11/28] Resolve dependent template bounds and defaults completely --- src/Type/Generic/TemplateTypeHelper.php | 8 +++---- src/Type/Generic/TemplateTypeMap.php | 6 +---- .../Type/Generic/TemplateTypeHelperTest.php | 24 +++++++++++++++++++ .../Type/Generic/TemplateTypeMapTest.php | 19 +++++++++++++++ 4 files changed, 48 insertions(+), 9 deletions(-) diff --git a/src/Type/Generic/TemplateTypeHelper.php b/src/Type/Generic/TemplateTypeHelper.php index 157385c0c18..fb1fb8ededd 100644 --- a/src/Type/Generic/TemplateTypeHelper.php +++ b/src/Type/Generic/TemplateTypeHelper.php @@ -82,8 +82,8 @@ public static function resolveTemplateTypes( public static function resolveToDefaults(Type $type): Type { return TypeTraverser::map($type, static function (Type $type, callable $traverse): Type { - if ($type instanceof TemplateType) { - return $traverse($type->getDefault() ?? $type->getBound()); + while ($type instanceof TemplateType) { + $type = $type->getDefault() ?? $type->getBound(); } return $traverse($type); @@ -93,8 +93,8 @@ public static function resolveToDefaults(Type $type): Type public static function resolveToBounds(Type $type): Type { return TypeTraverser::map($type, static function (Type $type, callable $traverse): Type { - if ($type instanceof TemplateType) { - return $traverse($type->getBound()); + while ($type instanceof TemplateType) { + $type = $type->getBound(); } return $traverse($type); diff --git a/src/Type/Generic/TemplateTypeMap.php b/src/Type/Generic/TemplateTypeMap.php index ed9d5dc25d2..ad9c7de689a 100644 --- a/src/Type/Generic/TemplateTypeMap.php +++ b/src/Type/Generic/TemplateTypeMap.php @@ -5,7 +5,6 @@ use PHPStan\Type\NeverType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; -use PHPStan\Type\TypeTraverser; use PHPStan\Type\TypeUtils; use function array_key_exists; use function count; @@ -252,10 +251,7 @@ public function resolveToBounds(): self if ($this->resolvedToBounds !== null) { return $this->resolvedToBounds; } - return $this->resolvedToBounds = $this->map(static fn (string $name, Type $type): Type => TypeTraverser::map( - $type, - static fn (Type $type, callable $traverse): Type => $type instanceof TemplateType ? $traverse($type->getDefault() ?? $type->getBound()) : $traverse($type), - )); + return $this->resolvedToBounds = $this->map(static fn (string $name, Type $type): Type => TemplateTypeHelper::resolveToDefaults($type)); } } diff --git a/tests/PHPStan/Type/Generic/TemplateTypeHelperTest.php b/tests/PHPStan/Type/Generic/TemplateTypeHelperTest.php index c54813969df..bba0a1671a2 100644 --- a/tests/PHPStan/Type/Generic/TemplateTypeHelperTest.php +++ b/tests/PHPStan/Type/Generic/TemplateTypeHelperTest.php @@ -4,13 +4,37 @@ use DateTime; use PHPStan\Testing\PHPStanTestCase; +use PHPStan\Type\ArrayType; +use PHPStan\Type\IntegerType; use PHPStan\Type\IntersectionType; use PHPStan\Type\ObjectType; +use PHPStan\Type\ObjectWithoutClassType; use PHPStan\Type\VerbosityLevel; +use stdClass; class TemplateTypeHelperTest extends PHPStanTestCase { + public function testResolveDependentBoundsAndDefaults(): void + { + $scope = TemplateTypeScope::createWithClass('DependentBounds'); + $variance = TemplateTypeVariance::createInvariant(); + $bound = new ObjectWithoutClassType(); + $default = new ObjectType(stdClass::class); + $t = TemplateTypeFactory::create($scope, 'T', $bound, $variance, default: $default); + $u = TemplateTypeFactory::create($scope, 'U', $t, $variance); + $v = TemplateTypeFactory::create($scope, 'V', $u, $variance); + + foreach ([$u, $v] as $type) { + $this->assertTrue($bound->equals(TemplateTypeHelper::resolveToBounds($type))); + $this->assertTrue($default->equals(TemplateTypeHelper::resolveToDefaults($type))); + } + + $array = new ArrayType(new IntegerType(), $v); + $this->assertTrue((new ArrayType(new IntegerType(), $bound))->equals(TemplateTypeHelper::resolveToBounds($array))); + $this->assertTrue((new ArrayType(new IntegerType(), $default))->equals(TemplateTypeHelper::resolveToDefaults($array))); + } + public function testIssue2512(): void { $templateType = TemplateTypeFactory::create( diff --git a/tests/PHPStan/Type/Generic/TemplateTypeMapTest.php b/tests/PHPStan/Type/Generic/TemplateTypeMapTest.php index ed6907ac611..4a8e2d51c6e 100644 --- a/tests/PHPStan/Type/Generic/TemplateTypeMapTest.php +++ b/tests/PHPStan/Type/Generic/TemplateTypeMapTest.php @@ -6,12 +6,31 @@ use InvalidArgumentException; use PHPStan\Testing\PHPStanTestCase; use PHPStan\Type\ObjectType; +use PHPStan\Type\ObjectWithoutClassType; use PHPStan\Type\VerbosityLevel; use PHPUnit\Framework\Attributes\DataProvider; +use stdClass; class TemplateTypeMapTest extends PHPStanTestCase { + public function testResolveDependentBoundsAndDefaults(): void + { + $scope = TemplateTypeScope::createWithClass('DependentBounds'); + $variance = TemplateTypeVariance::createInvariant(); + $default = new ObjectType(stdClass::class); + $t = TemplateTypeFactory::create($scope, 'T', new ObjectWithoutClassType(), $variance, default: $default); + $u = TemplateTypeFactory::create($scope, 'U', $t, $variance); + $v = TemplateTypeFactory::create($scope, 'V', $u, $variance); + $resolved = (new TemplateTypeMap(['T' => $t, 'U' => $u, 'V' => $v]))->resolveToBounds(); + + foreach (['T', 'U', 'V'] as $name) { + $type = $resolved->getType($name); + $this->assertNotNull($type); + $this->assertTrue($default->equals($type)); + } + } + public static function dataUnionWithLowerBoundTypes(): iterable { $map = (new TemplateTypeMap([ From dfd955ced88ccb6f73dc672237d64468c788ff27 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 6 Sep 2026 21:29:02 +0200 Subject: [PATCH 12/28] Preserve unresolved template identity in finite type comparisons --- src/Type/FiniteTypeSet.php | 7 ++++--- .../UnresolvedTemplateArgumentTypeTest.php | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/Type/FiniteTypeSet.php b/src/Type/FiniteTypeSet.php index fc7c9de02f0..6e575ebb117 100644 --- a/src/Type/FiniteTypeSet.php +++ b/src/Type/FiniteTypeSet.php @@ -4,6 +4,7 @@ use PHPStan\TrinaryLogic; use PHPStan\Type\Generic\TemplateType; +use PHPStan\Type\Generic\UnresolvedTemplateArgumentType; use function array_diff_key; use function array_key_exists; use function count; @@ -95,12 +96,12 @@ public static function create(array $types): ?self * with value identity for them (-0.0 === 0.0, NAN !== NAN). A type that merely contains * a finite value - an intersection with an accessory type, a whole single-case enum, a * conditional type resolving to a constant - is excluded by the equals() check: only a - * type that *is* the value can stand in for it. Template types are excluded outright, - * their comparison semantics are not value identity. + * type that *is* the value can stand in for it. Template types and unresolved arguments are + * excluded outright: their comparison semantics are not value identity. */ public static function key(Type $type): ?string { - if ($type instanceof TemplateType || $type instanceof UnionType || $type instanceof IntersectionType) { + if ($type instanceof TemplateType || $type instanceof UnresolvedTemplateArgumentType || $type instanceof UnionType || $type instanceof IntersectionType) { return null; } diff --git a/tests/PHPStan/Type/Generic/UnresolvedTemplateArgumentTypeTest.php b/tests/PHPStan/Type/Generic/UnresolvedTemplateArgumentTypeTest.php index 9204f1c8f99..7b7691b5bb2 100644 --- a/tests/PHPStan/Type/Generic/UnresolvedTemplateArgumentTypeTest.php +++ b/tests/PHPStan/Type/Generic/UnresolvedTemplateArgumentTypeTest.php @@ -5,7 +5,11 @@ use PhpParser\Node\Expr; use PhpParser\Node\Expr\Variable; use PHPStan\Testing\PHPStanTestCase; +use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Constant\ConstantIntegerType; +use PHPStan\Type\Constant\ConstantStringType; +use PHPStan\Type\Enum\EnumCaseObjectType; +use PHPStan\Type\FiniteTypeSet; use PHPStan\Type\GeneralizePrecision; use PHPStan\Type\IntegerType; use PHPStan\Type\MixedType; @@ -52,6 +56,19 @@ public function testEqualsIsOpaqueAndKeyedBySiteAndName(): void $this->assertFalse((new ConstantIntegerType(1))->equals($marker)); } + public function testFiniteValueIdentityDoesNotEraseMarkers(): void + { + foreach ([new ConstantIntegerType(123), new ConstantStringType('foo'), new ConstantBooleanType(true), new NullType(), new EnumCaseObjectType('PHPStan\Fixture\ManyCasesTestEnum', 'A')] as $initialType) { + $marker = self::marker(new Variable('a'), $initialType); + $union = new UnionType([$marker, new ConstantIntegerType(321)]); + $resolvedUnion = new UnionType([$initialType, new ConstantIntegerType(321)]); + + $this->assertNull(FiniteTypeSet::key($marker)); + $this->assertFalse($union->equals($resolvedUnion)); + $this->assertFalse($resolvedUnion->equals($union)); + } + } + public function testBehavesAsItsDelegate(): void { $marker = self::marker(new Variable('a'), new ConstantIntegerType(1)); From 6fd7b530c0241b69b2121a9373441aa72e63c34b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 6 Sep 2026 21:29:15 +0200 Subject: [PATCH 13/28] Keep PHPDoc template inference out of native construction types --- src/Analyser/ExprHandler/NewHandler.php | 7 ++- .../PHPStan/Analyser/nsrt/bug-15169-calls.php | 6 +- .../nsrt/native-template-arguments.php | 58 +++++++++++++++++++ .../WrongVariableNameInVarTagRuleTest.php | 6 +- 4 files changed, 70 insertions(+), 7 deletions(-) diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index d33508b7115..32a84d52d81 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -569,7 +569,7 @@ private function exactInstantiation(MutatingScope $scope, New_ $node, Name $clas return $objectType; } - $frame = $scope->getCurrentTemplateArgumentFrame(); + $frame = $allowUnresolved ? $scope->getCurrentTemplateArgumentFrame() : null; // the class's arguments when the constructor says nothing about them $unresolvedArguments = function () use ($classReflection, $node, $frame, $allowUnresolved, $isStatic, $resolvedClassName): Type { $types = $this->unresolvedArgumentList($classReflection, $node, $frame, $allowUnresolved); @@ -580,6 +580,11 @@ private function exactInstantiation(MutatingScope $scope, New_ $node, Name $clas return new GenericObjectType($resolvedClassName, $types, classReflection: $classReflection->withTypes($types)->asFinal()); }; + if (!$allowUnresolved) { + // Native types use the bounds or defaults, without PHPDoc inference. + return $unresolvedArguments(); + } + $assignedToProperty = $node->getAttribute(NewAssignedToPropertyVisitor::ATTRIBUTE_NAME); if ($assignedToProperty !== null) { $constructorVariants = $constructorMethod->getVariants(); diff --git a/tests/PHPStan/Analyser/nsrt/bug-15169-calls.php b/tests/PHPStan/Analyser/nsrt/bug-15169-calls.php index 25be0c92edc..1f941910f79 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-15169-calls.php +++ b/tests/PHPStan/Analyser/nsrt/bug-15169-calls.php @@ -95,10 +95,10 @@ function funcCall(C $c): void function instantiation(): void { - // the template argument is inferred from the phpdoc @param; the frame's - // resolution is shared by both flavours, so the native one sees it too + // The template argument is inferred from the phpdoc @param, so the native + // flavour uses its bound. assertType('Bug15169Calls\Generic<1>', new Generic(1)); - assertNativeType('Bug15169Calls\Generic<1>', new Generic(1)); + assertNativeType('Bug15169Calls\Generic', new Generic(1)); assertType('1', (new Generic(1))->value); assertNativeType('mixed', (new Generic(1))->value); diff --git a/tests/PHPStan/Analyser/nsrt/native-template-arguments.php b/tests/PHPStan/Analyser/nsrt/native-template-arguments.php index a13374ff40b..23f097c684e 100644 --- a/tests/PHPStan/Analyser/nsrt/native-template-arguments.php +++ b/tests/PHPStan/Analyser/nsrt/native-template-arguments.php @@ -90,3 +90,61 @@ function takesUnbounded(Unbounded $u): void function takesBoundedInt(BoundedInt $b): void { } + +/** @template T */ +class WithoutConstructor +{ +} + +/** + * @template T of int + * @extends BoundedInt + */ +class InheritedConstructor extends BoundedInt +{ +} + +/** + * @template T of object + * @template U of T + */ +class DependentBounds +{ +} + +function otherConstructors(): void +{ + assertNativeType('NativeTemplateArguments\WithoutConstructor', new WithoutConstructor()); + assertNativeType('NativeTemplateArguments\InheritedConstructor', new InheritedConstructor(1)); + assertNativeType('NativeTemplateArguments\DependentBounds', new DependentBounds()); +} + +class PropertyAssignment +{ + + /** @var WithoutConstructor */ + private WithoutConstructor $value; + + public function assign(): void + { + assertNativeType('NativeTemplateArguments\WithoutConstructor', $this->value = new WithoutConstructor()); + } + +} + +/** @template T of int */ +class StaticInstantiation +{ + + /** @param T $value */ + public function __construct($value) + { + } + + public static function create(): void + { + assertNativeType('NativeTemplateArguments\StaticInstantiation', new self(1)); + assertNativeType('static(NativeTemplateArguments\StaticInstantiation)', new static(1)); + } + +} diff --git a/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php b/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php index 3165a578280..7b7dac3eb84 100644 --- a/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php @@ -258,7 +258,7 @@ public static function dataReportWrongType(): iterable 14, ], [ - 'PHPDoc tag @var with type stdClass is not subtype of native type SplObjectStorage.', + 'PHPDoc tag @var with type stdClass is not subtype of native type SplObjectStorage.', 23, ], [ @@ -315,7 +315,7 @@ public static function dataReportWrongType(): iterable 14, ], [ - 'PHPDoc tag @var with type stdClass is not subtype of native type SplObjectStorage.', + 'PHPDoc tag @var with type stdClass is not subtype of native type SplObjectStorage.', 23, ], [ @@ -406,7 +406,7 @@ public static function dataReportWrongType(): iterable 14, ], [ - 'PHPDoc tag @var with type stdClass is not subtype of native type SplObjectStorage.', + 'PHPDoc tag @var with type stdClass is not subtype of native type SplObjectStorage.', 23, ], [ From 90fb324a510dacd80785721bae91d07f2f9080fb Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 6 Sep 2026 23:19:01 +0200 Subject: [PATCH 14/28] Ignore diagnostic call markers when deciding statement replay --- src/Analyser/MutatingScope.php | 12 +++++- .../Analyser/Generics/MinimalReWalkTest.php | 4 +- .../Analyser/Generics/data/minimal-rewalk.php | 7 ++++ tests/PHPStan/Analyser/ScopeTest.php | 38 +++++++++++++++++++ 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index a77adf8bcfd..38f922fa410 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1542,6 +1542,9 @@ public function getDifferingVariableRoots(self $other): ?array ]; foreach ($tables as [$ours, $theirs]) { foreach ($ours as $key => $holder) { + if ($holder->getExpr() instanceof PossiblyImpureCallExpr) { + continue; + } $theirHolder = $theirs[$key] ?? null; if ($theirHolder !== null && ($theirHolder === $holder || $holder->equals($theirHolder))) { continue; @@ -1553,7 +1556,7 @@ public function getDifferingVariableRoots(self $other): ?array $roots[$root] = true; } foreach ($theirs as $key => $holder) { - if (isset($ours[$key])) { + if (isset($ours[$key]) || $holder->getExpr() instanceof PossiblyImpureCallExpr) { continue; } $root = self::getVariableRootOfExpressionKey($key); @@ -1574,7 +1577,12 @@ public function getDifferingVariableRoots(self $other): ?array } $root = self::getVariableRootOfExpressionKey($key); if ($root === null) { - return null; + foreach ($holders as $holder) { + if (!$holder->getTypeHolder()->getExpr() instanceof PossiblyImpureCallExpr) { + return null; + } + } + continue; } $roots[$root] = true; } diff --git a/tests/PHPStan/Analyser/Generics/MinimalReWalkTest.php b/tests/PHPStan/Analyser/Generics/MinimalReWalkTest.php index ac9a43c79d4..cb7fa4471ca 100644 --- a/tests/PHPStan/Analyser/Generics/MinimalReWalkTest.php +++ b/tests/PHPStan/Analyser/Generics/MinimalReWalkTest.php @@ -26,8 +26,8 @@ public function testStatementsNotMentioningTheSiteVariableAreReplayed(): void } $this->assertSame(1, $counters['bodiesWithSites']); - // the `new`, the property send, and the assertType() reading the variable - $this->assertSame(3, $counters['statementsReWalked']); + // The construction, property send, assertion and method call read $c. + $this->assertSame(4, $counters['statementsReWalked']); // the ten statements never mentioning $c $this->assertSame(10, $counters['statementsReplayed']); } diff --git a/tests/PHPStan/Analyser/Generics/data/minimal-rewalk.php b/tests/PHPStan/Analyser/Generics/data/minimal-rewalk.php index 01fd1f288be..d7a46a1016d 100644 --- a/tests/PHPStan/Analyser/Generics/data/minimal-rewalk.php +++ b/tests/PHPStan/Analyser/Generics/data/minimal-rewalk.php @@ -20,6 +20,12 @@ public function add($item): void { } + /** @return self */ + public function remember(): self + { + return $this; + } + } class Foo @@ -33,6 +39,7 @@ public function doFoo(int $x): void $c = new Collection([1]); $this->ints = $c; assertType('MinimalReWalk\Collection', $c); + $c->remember(); $a = $x + 1; $b = $a * 2; $d = $b - 1; diff --git a/tests/PHPStan/Analyser/ScopeTest.php b/tests/PHPStan/Analyser/ScopeTest.php index e83ed193540..88d9753efc1 100644 --- a/tests/PHPStan/Analyser/ScopeTest.php +++ b/tests/PHPStan/Analyser/ScopeTest.php @@ -3,7 +3,10 @@ namespace PHPStan\Analyser; use PhpParser\Node\Expr\ConstFetch; +use PhpParser\Node\Expr\MethodCall; +use PhpParser\Node\Expr\Variable; use PhpParser\Node\Name\FullyQualified; +use PHPStan\Node\Expr\PossiblyImpureCallExpr; use PHPStan\Testing\PHPStanTestCase; use PHPStan\TrinaryLogic; use PHPStan\Type\Constant\ConstantArrayType; @@ -11,6 +14,7 @@ use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\IntegerRangeType; +use PHPStan\Type\IntegerType; use PHPStan\Type\ObjectType; use PHPStan\Type\StringType; use PHPStan\Type\Type; @@ -271,4 +275,38 @@ public function testMaybeDefinedVariables(): void $this->assertSame(['b'], $scope->getMaybeDefinedVariables()); } + public function testPossiblyImpureCallsDoNotAffectDifferingVariableRoots(): void + { + $scope = self::getContainer()->getByType(ScopeFactory::class)->create(ScopeContext::create('file.php')); + $variable = new Variable('a'); + $expr = new PossiblyImpureCallExpr(new MethodCall($variable, 'remember'), $variable, 'Collection::remember()'); + $intScope = $scope->assignExpression($expr, new IntegerType(), new IntegerType()); + $stringScope = $scope->assignExpression($expr, new StringType(), new StringType()); + + $this->assertSame([], $intScope->getDifferingVariableRoots($scope)); + $this->assertSame([], $scope->getDifferingVariableRoots($intScope)); + $this->assertSame([], $intScope->getDifferingVariableRoots($stringScope)); + $this->assertSame([], $stringScope->getDifferingVariableRoots($intScope)); + + $variableScope = $intScope->assignVariable('a', new StringType(), new StringType(), TrinaryLogic::createYes()); + $this->assertSame(['a'], $variableScope->getDifferingVariableRoots($stringScope)); + $this->assertSame(['a'], $stringScope->getDifferingVariableRoots($variableScope)); + } + + public function testPossiblyImpureConditionalCallsDoNotAffectDifferingVariableRoots(): void + { + $scope = self::getContainer()->getByType(ScopeFactory::class)->create(ScopeContext::create('file.php')); + $variable = new Variable('a'); + $expr = new PossiblyImpureCallExpr(new MethodCall($variable, 'remember'), $variable, 'Collection::remember()'); + $conditionalScope = $scope->addConditionalExpressions($scope->getExprPrinter()->printExpr($expr), [ + new ConditionalExpressionHolder( + ['$condition' => ExpressionTypeHolder::createYes(new Variable('condition'), new ConstantBooleanType(true))], + ExpressionTypeHolder::createYes($expr, new IntegerType()), + ), + ]); + + $this->assertSame([], $scope->getDifferingVariableRoots($conditionalScope)); + $this->assertSame([], $conditionalScope->getDifferingVariableRoots($scope)); + } + } From 890671149a038c52feb93233c75c93012b0793e5 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 7 Sep 2026 07:46:27 +0200 Subject: [PATCH 15/28] Reuse analysed bodies for array_map and immediately invoked closures --- src/Analyser/ClosureParameterTypes.php | 21 +++++ .../Helper/ClosureParameterResolver.php | 63 ++++++++++++++ .../Helper/ClosureTypeResolver.php | 82 ++++--------------- .../Generics/TemplateArgumentStats.php | 1 + src/Analyser/NodeScopeResolver.php | 11 ++- .../Analyser/Generics/ClosureAnalysisTest.php | 40 +++++++++ .../data/array-map-closure-analysis.php | 23 ++++++ .../immediately-invoked-closure-analysis.php | 22 +++++ 8 files changed, 192 insertions(+), 71 deletions(-) create mode 100644 src/Analyser/ClosureParameterTypes.php create mode 100644 src/Analyser/ExprHandler/Helper/ClosureParameterResolver.php create mode 100644 tests/PHPStan/Analyser/Generics/ClosureAnalysisTest.php create mode 100644 tests/PHPStan/Analyser/Generics/data/array-map-closure-analysis.php create mode 100644 tests/PHPStan/Analyser/Generics/data/immediately-invoked-closure-analysis.php diff --git a/src/Analyser/ClosureParameterTypes.php b/src/Analyser/ClosureParameterTypes.php new file mode 100644 index 00000000000..86c418be3ea --- /dev/null +++ b/src/Analyser/ClosureParameterTypes.php @@ -0,0 +1,21 @@ +getAttribute(ArrayMapArgVisitor::ATTRIBUTE_NAME); + $immediatelyInvokedArgs = $expr->getAttribute(ImmediatelyInvokedClosureVisitor::ARGS_ATTRIBUTE_NAME); + $intrinsicArgs = $arrayMapArgs ?? $immediatelyInvokedArgs; + if ($intrinsicArgs === null) { + return new ClosureParameterTypes( + $this->nodeScopeResolver->createCallableParameters($scope, $expr, $callArgs, $passedToType), + $this->nodeScopeResolver->createNativeCallableParameters($scope, $expr, $callArgs, $nativePassedToType), + ); + } + + $parameters = []; + $nativeParameters = []; + foreach ($intrinsicArgs as $arg) { + $result = $storage !== null ? $storage->findExpressionResult($arg->value) : null; + $type = $result !== null ? $result->getType() : $this->nodeScopeResolver->readScopeStateOrSyntheticType($arg->value, $scope); + $nativeType = $result !== null ? $result->getNativeType() : $this->nodeScopeResolver->readScopeStateOrSyntheticType($arg->value, $scope->doNotTreatPhpDocTypesAsCertain()); + if ($arrayMapArgs !== null) { + $type = $type->getIterableValueType(); + $nativeType = $nativeType->getIterableValueType(); + } + $parameters[] = new DummyParameter('item', $type, optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); + $nativeParameters[] = new DummyParameter('item', $nativeType, optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); + } + + return new ClosureParameterTypes($parameters, $nativeParameters); + } + +} diff --git a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php index a847048eb42..a82b91ce177 100644 --- a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php +++ b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php @@ -11,6 +11,7 @@ use PhpParser\NodeFinder; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\Generics\TemplateArgumentStats; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; @@ -23,8 +24,6 @@ use PHPStan\Node\ExecutionEndNode; use PHPStan\Node\InvalidateExprNode; use PHPStan\Node\PropertyAssignNode; -use PHPStan\Parser\ArrayMapArgVisitor; -use PHPStan\Parser\ImmediatelyInvokedClosureVisitor; use PHPStan\Reflection\Callables\SimpleImpurePoint; use PHPStan\Reflection\Callables\SimpleThrowPoint; use PHPStan\Reflection\ExtendedParameterReflection; @@ -33,7 +32,6 @@ use PHPStan\Reflection\Native\NativeParameterReflection; use PHPStan\Reflection\ParameterReflection; use PHPStan\Reflection\PassedByReference; -use PHPStan\Reflection\Php\DummyParameter; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\ClosureType; @@ -82,6 +80,7 @@ final class ClosureTypeResolver implements PerFileAnalysisResettable public function __construct( private NodeScopeResolver $nodeScopeResolver, private InitializerExprTypeResolver $initializerExprTypeResolver, + private ClosureParameterResolver $closureParameterResolver, ) { } @@ -155,6 +154,9 @@ public function getClosureType( ); } + if (TemplateArgumentStats::$enabled) { + TemplateArgumentStats::increment('closureTypeBodyWalks'); + } if ($expr instanceof ArrowFunction) { $arrowScope = $scope->enterArrowFunctionWithoutReflection($expr, $callableParameters, $nativeCallableParameters); @@ -308,10 +310,6 @@ public function buildClosureTypeForClosure( ?ExpressionResultStorage $storage = null, ): ClosureType { - if ($this->bodyWalkHasOwnParameterTypes($expr)) { - return $this->getClosureType($native ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope, $expr, false, $storage); - } - [$parameters, $isVariadic, $callableParameters, $nativeCallableParameters] = $this->buildParametersAndAcceptors($scope, $expr, $storage); return $this->buildClosureTypeFromClosureWalk( @@ -360,10 +358,6 @@ public function buildClosureTypeForArrowFunction( ?ExpressionResultStorage $storage = null, ): ClosureType { - if ($this->bodyWalkHasOwnParameterTypes($expr)) { - return $this->getClosureType($native ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope, $expr, false, $storage); - } - [$parameters, $isVariadic, $callableParameters, $nativeCallableParameters] = $this->buildParametersAndAcceptors($scope, $expr, $storage); $returnType = $this->resolveArrowFunctionReturnType($scope, $arrowScope, $expr, $native, $storage); @@ -379,24 +373,6 @@ public function buildClosureTypeForArrowFunction( )); } - /** - * Whether getClosureType() would walk the body with different parameter types - * than NodeScopeResolver's single walk (processClosureNode()/ - * processArrowFunctionNode()) did. array_map() callbacks and immediately - * invoked closures get their parameter types from the array element type / - * the invocation arguments in getClosureType(), whereas the single walk types - * them from the closure's passed-to callable type - so the return type read - * from the gathered scopes would differ, and getClosureType() must re-walk. - */ - /** - * The expression roots this closure's type can read from the enclosing - * scope: '$this' and the use()d variables for closures, '$this' and - * every body variable that is not a parameter for arrow functions. Null - * when the body accesses variables dynamically ($$name, compact(), - * get_defined_vars()) and the whole scope must key the cache. - * - * @return list|null - */ /** * The cache key of everything this closure's type can depend on: the * free-variable slice of the scope plus the parameter types the caller @@ -485,12 +461,6 @@ private function freeVariableRoots(Node\Expr\Closure|ArrowFunction $expr): ?arra return $rootList; } - private function bodyWalkHasOwnParameterTypes(Node\Expr\Closure|ArrowFunction $expr): bool - { - return $expr->getAttribute(ArrayMapArgVisitor::ATTRIBUTE_NAME) !== null - || $expr->getAttribute(ImmediatelyInvokedClosureVisitor::ARGS_ATTRIBUTE_NAME) !== null; - } - /** * Reads the type of a gathered/argument expression from its stored result * (the walk that produced it), the scope only when no storage is available @@ -818,41 +788,19 @@ private function buildParametersAndAcceptors( { [$parameters, $isVariadic] = $this->buildDeclaredParameters($scope, $expr); - $callableParameters = null; - $nativeCallableParameters = null; - $arrayMapArgs = $expr->getAttribute(ArrayMapArgVisitor::ATTRIBUTE_NAME); - $immediatelyInvokedArgs = $expr->getAttribute(ImmediatelyInvokedClosureVisitor::ARGS_ATTRIBUTE_NAME); - if ($arrayMapArgs !== null) { - $callableParameters = []; - $nativeCallableParameters = []; - foreach ($arrayMapArgs as $funcCallArg) { - // array_map()'s array arguments were walked before the callback - // (processArgs orders closures last), so their results are stored - $callableParameters[] = new DummyParameter('item', $this->readExprType($storage, $funcCallArg->value, $scope, false)->getIterableValueType(), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); - $nativeCallableParameters[] = new DummyParameter('item', $this->readExprType($storage, $funcCallArg->value, $scope->doNotTreatPhpDocTypesAsCertain(), true)->getIterableValueType(), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); - } - } elseif ($immediatelyInvokedArgs !== null) { - foreach ($immediatelyInvokedArgs as $immediatelyInvokedArg) { - // an immediately invoked closure is the callee; the call handler - // walks its invocation arguments BEFORE the closure, so their - // results are stored (see FuncCallHandler) - $argValue = $immediatelyInvokedArg->value; - $callableParameters[] = new DummyParameter('item', $this->readExprType($storage, $argValue, $scope, false), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); - $nativeCallableParameters[] = new DummyParameter('item', $this->readExprType($storage, $argValue, $scope->doNotTreatPhpDocTypesAsCertain(), true), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); - } - } else { - $inFunctionCallsStackCount = count($scope->inFunctionCallsStack); - if ($inFunctionCallsStackCount > 0) { - [, $inParameter] = $scope->inFunctionCallsStack[$inFunctionCallsStackCount - 1]; - if ($inParameter !== null) { - $callableParameters = $this->nodeScopeResolver->createCallableParameters($scope, $expr, null, $inParameter->getType()); - $nativeType = $inParameter instanceof ExtendedParameterReflection ? $inParameter->getNativeType() : $inParameter->getType(); - $nativeCallableParameters = $this->nodeScopeResolver->createNativeCallableParameters($scope, $expr, null, $nativeType); - } + $passedToType = null; + $nativePassedToType = null; + $inFunctionCallsStackCount = count($scope->inFunctionCallsStack); + if ($inFunctionCallsStackCount > 0) { + [, $inParameter] = $scope->inFunctionCallsStack[$inFunctionCallsStackCount - 1]; + if ($inParameter !== null) { + $passedToType = $inParameter->getType(); + $nativePassedToType = $inParameter instanceof ExtendedParameterReflection ? $inParameter->getNativeType() : $inParameter->getType(); } } + $parameterTypes = $this->closureParameterResolver->resolve($scope, $expr, $storage, null, $passedToType, $nativePassedToType); - return [$parameters, $isVariadic, $callableParameters, $nativeCallableParameters]; + return [$parameters, $isVariadic, $parameterTypes->parameters, $parameterTypes->nativeParameters]; } /** diff --git a/src/Analyser/Generics/TemplateArgumentStats.php b/src/Analyser/Generics/TemplateArgumentStats.php index 168c506a61e..e809cc873f6 100644 --- a/src/Analyser/Generics/TemplateArgumentStats.php +++ b/src/Analyser/Generics/TemplateArgumentStats.php @@ -42,6 +42,7 @@ final class TemplateArgumentStats 'statementsReplayed' => 0, 'statementsReWalked' => 0, 'earlyExits' => 0, + 'closureTypeBodyWalks' => 0, 'resolvedBySend' => 0, 'resolvedWithLowerBounds' => 0, 'resolvedToInitial' => 0, diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index f9c7fd043b6..a4b85ccbc35 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -31,6 +31,7 @@ use PhpParser\Node\Stmt\Switch_; use PhpParser\NodeFinder; use PHPStan\Analyser\ExprHandler\AssignHandler; +use PHPStan\Analyser\ExprHandler\Helper\ClosureParameterResolver; use PHPStan\Analyser\ExprHandler\Helper\ClosureTypeResolver; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; use PHPStan\Analyser\ExprHandler\Helper\VirtualExprResultHelper; @@ -1936,8 +1937,9 @@ private function processClosureNodeInternal( $byRefUses = []; $closureCallArgs = $expr->getAttribute(ClosureArgVisitor::ATTRIBUTE_NAME); - $callableParameters = $this->createCallableParameters($scope, $expr, $closureCallArgs, $passedToType); - $nativeCallableParameters = $this->createNativeCallableParameters($scope, $expr, $closureCallArgs, $nativePassedToType); + $parameterTypes = $this->container->getByType(ClosureParameterResolver::class)->resolve($scope, $expr, $storage, $closureCallArgs, $passedToType, $nativePassedToType); + $callableParameters = $parameterTypes->parameters; + $nativeCallableParameters = $parameterTypes->nativeParameters; $useScope = $scope; foreach ($expr->uses as $use) { @@ -2263,8 +2265,9 @@ public function processArrowFunctionNode( } $arrowFunctionCallArgs = $expr->getAttribute(ArrowFunctionArgVisitor::ATTRIBUTE_NAME); - $callableParameters = $this->createCallableParameters($scope, $expr, $arrowFunctionCallArgs, $passedToType); - $nativeCallableParameters = $this->createNativeCallableParameters($scope, $expr, $arrowFunctionCallArgs, $nativePassedToType); + $parameterTypes = $this->container->getByType(ClosureParameterResolver::class)->resolve($scope, $expr, $storage, $arrowFunctionCallArgs, $passedToType, $nativePassedToType); + $callableParameters = $parameterTypes->parameters; + $nativeCallableParameters = $parameterTypes->nativeParameters; $arrowFunctionScope = $scope->enterArrowFunction($expr, $callableParameters, $nativeCallableParameters); if ($arrowFunctionScope->getAnonymousFunctionReflection() === null) { throw new ShouldNotHappenException(); diff --git a/tests/PHPStan/Analyser/Generics/ClosureAnalysisTest.php b/tests/PHPStan/Analyser/Generics/ClosureAnalysisTest.php new file mode 100644 index 00000000000..a52b0b37fa2 --- /dev/null +++ b/tests/PHPStan/Analyser/Generics/ClosureAnalysisTest.php @@ -0,0 +1,40 @@ + */ + public static function dataIntrinsicClosures(): iterable + { + yield [__DIR__ . '/data/immediately-invoked-closure-analysis.php']; + yield [__DIR__ . '/data/array-map-closure-analysis.php']; + } + + #[DataProvider('dataIntrinsicClosures')] + public function testIntrinsicClosuresReuseBodyAnalysis(string $file): void + { + TemplateArgumentStats::reset(); + TemplateArgumentStats::$enabled = true; + try { + $asserts = self::gatherAssertTypes($file); + $this->assertSame(0, TemplateArgumentStats::getCounters()['closureTypeBodyWalks']); + foreach ($asserts as $args) { + $this->assertFileAsserts(...$args); + } + } finally { + TemplateArgumentStats::$enabled = false; + } + } + + public static function getAdditionalConfigFiles(): array + { + return array_merge(parent::getAdditionalConfigFiles(), [__DIR__ . '/../../../../conf/bleedingEdge.neon']); + } + +} diff --git a/tests/PHPStan/Analyser/Generics/data/array-map-closure-analysis.php b/tests/PHPStan/Analyser/Generics/data/array-map-closure-analysis.php new file mode 100644 index 00000000000..e6b0e44bded --- /dev/null +++ b/tests/PHPStan/Analyser/Generics/data/array-map-closure-analysis.php @@ -0,0 +1,23 @@ + $items */ +function doFoo(array $items): void +{ + $result = array_map(static function (int $value): int { + assertType('int<1, max>', $value); + assertNativeType('int', $value); + + return $value; + }, $items); + assertType('list>', $result); + assertNativeType('array', $result); + + $result = array_map(static fn (int $value): int => $value, $items); + assertType('list>', $result); + assertNativeType('array', $result); +} diff --git a/tests/PHPStan/Analyser/Generics/data/immediately-invoked-closure-analysis.php b/tests/PHPStan/Analyser/Generics/data/immediately-invoked-closure-analysis.php new file mode 100644 index 00000000000..e4eecf0782d --- /dev/null +++ b/tests/PHPStan/Analyser/Generics/data/immediately-invoked-closure-analysis.php @@ -0,0 +1,22 @@ + $items */ +function doFoo(array $items): void +{ + $result = (static function (array $values): array { + assertType('list>', $values); + assertNativeType('array', $values); + + return $values; + })($items); + assertType('list>', $result); + + $result = (static fn (array $values): array => $values)($items); + assertType('list>', $result); + assertNativeType('array', $result); +} From 95ed4fe15d448989f009104f6c485ee7ec825d12 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 7 Sep 2026 07:56:53 +0200 Subject: [PATCH 16/28] Carry template resolution policy in immutable analysis contexts --- .../ExprHandler/ArrowFunctionHandler.php | 2 +- src/Analyser/ExprHandler/AssignHandler.php | 2 +- src/Analyser/ExprHandler/FuncCallHandler.php | 4 +- src/Analyser/ExprHandler/MatchHandler.php | 6 +- .../ExprHandler/MethodCallHandler.php | 2 +- src/Analyser/ExprHandler/NewHandler.php | 8 +-- .../ExprHandler/StaticCallHandler.php | 2 +- src/Analyser/ExprHandler/ThrowHandler.php | 2 +- .../Virtual/FunctionCallableNodeHandler.php | 2 +- .../InstantiationCallableNodeHandler.php | 2 +- .../Virtual/MethodCallableNodeHandler.php | 4 +- .../StaticMethodCallableNodeHandler.php | 4 +- src/Analyser/ExpressionContext.php | 30 +++++++-- src/Analyser/NodeScopeResolver.php | 24 ++++--- src/Analyser/StatementContext.php | 27 ++++++-- .../StmtHandler/BreakContinueHandler.php | 2 +- .../StmtHandler/ClassConstHandler.php | 2 +- src/Analyser/StmtHandler/ClassLikeHandler.php | 4 +- .../StmtHandler/ClassMethodHandler.php | 2 +- src/Analyser/StmtHandler/ConstHandler.php | 2 +- src/Analyser/StmtHandler/DeclareHandler.php | 2 +- src/Analyser/StmtHandler/DoWhileHandler.php | 6 +- src/Analyser/StmtHandler/EchoHandler.php | 2 +- src/Analyser/StmtHandler/EnumCaseHandler.php | 2 +- .../StmtHandler/ExpressionHandler.php | 2 +- src/Analyser/StmtHandler/ForHandler.php | 14 ++-- src/Analyser/StmtHandler/ForeachHandler.php | 6 +- src/Analyser/StmtHandler/FunctionHandler.php | 2 +- src/Analyser/StmtHandler/GlobalHandler.php | 2 +- src/Analyser/StmtHandler/IfHandler.php | 4 +- src/Analyser/StmtHandler/PropertyHandler.php | 2 +- src/Analyser/StmtHandler/ReturnHandler.php | 2 +- .../StmtHandler/StaticVariableHandler.php | 4 +- src/Analyser/StmtHandler/SwitchHandler.php | 4 +- src/Analyser/StmtHandler/UnsetHandler.php | 4 +- src/Analyser/StmtHandler/WhileHandler.php | 8 +-- .../Analyser/Generics/AnalysisContextTest.php | 67 +++++++++++++++++++ .../data/explicit-analysis-context.php | 10 +++ 38 files changed, 194 insertions(+), 82 deletions(-) create mode 100644 tests/PHPStan/Analyser/Generics/AnalysisContextTest.php create mode 100644 tests/PHPStan/Analyser/Generics/data/explicit-analysis-context.php diff --git a/src/Analyser/ExprHandler/ArrowFunctionHandler.php b/src/Analyser/ExprHandler/ArrowFunctionHandler.php index b87201da16e..fb027e55e66 100644 --- a/src/Analyser/ExprHandler/ArrowFunctionHandler.php +++ b/src/Analyser/ExprHandler/ArrowFunctionHandler.php @@ -39,7 +39,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { - $arrowFunctionResult = $nodeScopeResolver->processArrowFunctionNode($stmt, $expr, $scope, $storage, $nodeCallback, null); + $arrowFunctionResult = $nodeScopeResolver->processArrowFunctionNode($stmt, $expr, $scope, $storage, $nodeCallback, null, context: $context); $result = $arrowFunctionResult->getExpressionResult(); // A plain typeCallback recursing through getClosureType() would re-walk diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 43a486f1e4b..c4f5e8af42a 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -1117,7 +1117,7 @@ public function applyWrite( if ($if === null) { $if = $assignedExpr->cond; } - $condScope = $nodeScopeResolver->processExprNode($stmt, $assignedExpr->cond, $scope, $storage->duplicate(), new NoopNodeCallback(), ExpressionContext::createDeep())->getScope(); + $condScope = $nodeScopeResolver->processExprNode($stmt, $assignedExpr->cond, $scope, $storage->duplicate(), new NoopNodeCallback(), ExpressionContext::createDeep(resolveTemplateArguments: false))->getScope(); $truthySpecifiedTypes = $this->defaultNarrowingHelper->specifyTypesForNode($condScope, $assignedExpr->cond, TypeSpecifierContext::createTruthy()); $falseySpecifiedTypes = $this->defaultNarrowingHelper->specifyTypesForNode($condScope, $assignedExpr->cond, TypeSpecifierContext::createFalsey()); $truthyScope = $condScope->applySpecifiedTypes($truthySpecifiedTypes); diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index e7d32ebd4bb..7c357151159 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -209,8 +209,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // properties array resolve from stored results instead of unprocessed // nodes; processArgs() below processes them again as clone()'s arguments, // so the NoopNodeCallback here avoids duplicate node-callbacks. - $cloneObjectArgResult = $nodeScopeResolver->processExprNode($stmt, $normalizedExpr->getArgs()[0]->value, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); - $clonePropertiesArgResult = $nodeScopeResolver->processExprNode($stmt, $normalizedExpr->getArgs()[1]->value, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); + $cloneObjectArgResult = $nodeScopeResolver->processExprNode($stmt, $normalizedExpr->getArgs()[0]->value, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()->withoutTemplateArgumentResolution()); + $clonePropertiesArgResult = $nodeScopeResolver->processExprNode($stmt, $normalizedExpr->getArgs()[1]->value, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()->withoutTemplateArgumentResolution()); $clonePropertiesArgType = $clonePropertiesArgResult->getType(); // the cloned type is composed from the object argument's result - // no synthetic Clone_ walk diff --git a/src/Analyser/ExprHandler/MatchHandler.php b/src/Analyser/ExprHandler/MatchHandler.php index 4420db342cf..4a35250c9c3 100644 --- a/src/Analyser/ExprHandler/MatchHandler.php +++ b/src/Analyser/ExprHandler/MatchHandler.php @@ -281,7 +281,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $matchArmBodyScope, $storage, $nodeCallback, - ExpressionContext::createTopLevel(), + ExpressionContext::createTopLevel($context->shouldResolveTemplateArguments()), ); $armScope = $armResult->getScope(); $scope = $scope->addTemplateArgumentConstraints($armScope->getTemplateArgumentConstraints()); @@ -321,7 +321,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $defaultArmBodyScope = $matchScope; $matchArmBody = new MatchExpressionArmBody($matchScope, $arm->body); $armNodes[$i] = new MatchExpressionArm($matchArmBody, [], $arm->getStartLine()); - $armResult = $nodeScopeResolver->processExprNode($stmt, $arm->body, $matchScope, $storage, $nodeCallback, ExpressionContext::createTopLevel()); + $armResult = $nodeScopeResolver->processExprNode($stmt, $arm->body, $matchScope, $storage, $nodeCallback, ExpressionContext::createTopLevel($context->shouldResolveTemplateArguments())); $matchScope = $armResult->getScope(); $scope = $scope->addTemplateArgumentConstraints($matchScope->getTemplateArgumentConstraints()); $hasYield = $hasYield || $armResult->hasYield(); @@ -429,7 +429,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $bodyScope, $storage, $nodeCallback, - ExpressionContext::createTopLevel(), + ExpressionContext::createTopLevel($context->shouldResolveTemplateArguments()), ); $armScope = $armResult->getScope(); $scope = $scope->addTemplateArgumentConstraints($armScope->getTemplateArgumentConstraints()); diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index bf91b2dc5be..40151a37907 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -96,7 +96,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // its stored ExpressionResult instead of reading the unprocessed node via // Scope::getType(). processArgs() below processes it again as call()'s first // argument; the NoopNodeCallback here avoids a duplicate node-callback. - $newThisResult = $nodeScopeResolver->processExprNode($stmt, $expr->getArgs()[0]->value, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); + $newThisResult = $nodeScopeResolver->processExprNode($stmt, $expr->getArgs()[0]->value, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()->withoutTemplateArgumentResolution()); $closureCallScope = $scope->enterClosureCall( $newThisResult->getType(), $newThisResult->getNativeType(), diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 32a84d52d81..f10062d3ae4 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -160,7 +160,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $constructorResult = $node; }); try { - $nodeScopeResolver->processStmtNode($expr->class, $scope, $storage, $nodeCallback, StatementContext::createTopLevel()); + $nodeScopeResolver->processStmtNode($expr->class, $scope, $storage, $nodeCallback, StatementContext::createTopLevel($context->shouldResolveTemplateArguments())); } finally { $nodeScopeResolver->popNodeGatherer(); } @@ -170,7 +170,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = $constructorResult->getImpurePoints(); } } else { - $nodeScopeResolver->processStmtNode($expr->class, $scope, $storage, $nodeCallback, StatementContext::createTopLevel()); + $nodeScopeResolver->processStmtNode($expr->class, $scope, $storage, $nodeCallback, StatementContext::createTopLevel($context->shouldResolveTemplateArguments())); if (!$constructorReflection->hasSideEffects()->no()) { $certain = $constructorReflection->isPure()->no(); $impurePoints[] = new ImpurePoint( @@ -183,7 +183,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } } } else { - $nodeScopeResolver->processStmtNode($expr->class, $scope, $storage, $nodeCallback, StatementContext::createTopLevel()); + $nodeScopeResolver->processStmtNode($expr->class, $scope, $storage, $nodeCallback, StatementContext::createTopLevel($context->shouldResolveTemplateArguments())); } if ($parametersAcceptor !== null) { @@ -204,7 +204,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // the not-yet-stored New_ node, which would re-enter this handler. $objectClasses = $classResult->getType()->getObjectTypeOrClassStringObjectType()->getObjectClassNames(); if (count($objectClasses) === 1) { - $objectExprResult = $nodeScopeResolver->processExprNode($stmt, new New_(new Name($objectClasses[0]), attributes: [TemplateArgumentFrame::SYNTHETIC_SITE_ATTRIBUTE => true]), $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); + $objectExprResult = $nodeScopeResolver->processExprNode($stmt, new New_(new Name($objectClasses[0]), attributes: [TemplateArgumentFrame::SYNTHETIC_SITE_ATTRIBUTE => true]), $scope, $storage, new NoopNodeCallback(), $context->enterDeep()->withoutTemplateArgumentResolution()); $className = $objectClasses[0]; $additionalThrowPoints = $objectExprResult->getThrowPoints(); } else { diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index 5f04c592b95..fa45770a113 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -232,7 +232,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $objectClasses = $classResult->getType()->getObjectTypeOrClassStringObjectType()->getObjectClassNames(); } if (count($objectClasses) === 1) { - $objectExprResult = $nodeScopeResolver->processExprNode($stmt, new StaticCall(new Name($objectClasses[0]), $expr->name, []), $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); + $objectExprResult = $nodeScopeResolver->processExprNode($stmt, new StaticCall(new Name($objectClasses[0]), $expr->name, []), $scope, $storage, new NoopNodeCallback(), $context->enterDeep()->withoutTemplateArgumentResolution()); $additionalThrowPoints = $objectExprResult->getThrowPoints(); } else { $additionalThrowPoints = [InternalThrowPoint::createImplicit($scope, $expr)]; diff --git a/src/Analyser/ExprHandler/ThrowHandler.php b/src/Analyser/ExprHandler/ThrowHandler.php index cea16377a81..0d866b66376 100644 --- a/src/Analyser/ExprHandler/ThrowHandler.php +++ b/src/Analyser/ExprHandler/ThrowHandler.php @@ -41,7 +41,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { - $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()->enterThrow()); + $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, ExpressionContext::createDeep($context->shouldResolveTemplateArguments())->enterThrow()); return $this->expressionResultFactory->create( $scope, diff --git a/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php index b15dd374011..d6b83b33a04 100644 --- a/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php @@ -51,7 +51,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = false; $nameResult = null; if ($expr->getName() instanceof Expr) { - $nameResult = $nodeScopeResolver->processExprNode($stmt, $expr->getName(), $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $nameResult = $nodeScopeResolver->processExprNode($stmt, $expr->getName(), $scope, $storage, $nodeCallback, ExpressionContext::createDeep($context->shouldResolveTemplateArguments())); $scope = $nameResult->getScope(); $hasYield = $nameResult->hasYield(); $throwPoints = $nameResult->getThrowPoints(); diff --git a/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php index 492d3f3bfaa..c47d531691e 100644 --- a/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php @@ -47,7 +47,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $hasYield = false; $isAlwaysTerminating = false; if ($expr->getClass() instanceof Expr) { - $classResult = $nodeScopeResolver->processExprNode($stmt, $expr->getClass(), $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $classResult = $nodeScopeResolver->processExprNode($stmt, $expr->getClass(), $scope, $storage, $nodeCallback, ExpressionContext::createDeep($context->shouldResolveTemplateArguments())); $scope = $classResult->getScope(); $hasYield = $classResult->hasYield(); $throwPoints = $classResult->getThrowPoints(); diff --git a/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php index 19a1bc47f86..fed305786f6 100644 --- a/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php @@ -45,14 +45,14 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; - $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->getVar(), $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->getVar(), $scope, $storage, $nodeCallback, ExpressionContext::createDeep($context->shouldResolveTemplateArguments())); $scope = $varResult->getScope(); $hasYield = $varResult->hasYield(); $throwPoints = $varResult->getThrowPoints(); $impurePoints = $varResult->getImpurePoints(); $isAlwaysTerminating = false; if ($expr->getName() instanceof Expr) { - $nameResult = $nodeScopeResolver->processExprNode($stmt, $expr->getName(), $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $nameResult = $nodeScopeResolver->processExprNode($stmt, $expr->getName(), $scope, $storage, $nodeCallback, ExpressionContext::createDeep($context->shouldResolveTemplateArguments())); $scope = $nameResult->getScope(); $hasYield = $hasYield || $nameResult->hasYield(); $throwPoints = array_merge($throwPoints, $nameResult->getThrowPoints()); diff --git a/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php index b3225d5336a..c7cf02715cd 100644 --- a/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php @@ -48,7 +48,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $hasYield = false; $isAlwaysTerminating = false; if ($expr->getClass() instanceof Expr) { - $classResult = $nodeScopeResolver->processExprNode($stmt, $expr->getClass(), $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $classResult = $nodeScopeResolver->processExprNode($stmt, $expr->getClass(), $scope, $storage, $nodeCallback, ExpressionContext::createDeep($context->shouldResolveTemplateArguments())); $scope = $classResult->getScope(); $hasYield = $classResult->hasYield(); $throwPoints = $classResult->getThrowPoints(); @@ -56,7 +56,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = $classResult->isAlwaysTerminating(); } if ($expr->getName() instanceof Expr) { - $nameResult = $nodeScopeResolver->processExprNode($stmt, $expr->getName(), $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $nameResult = $nodeScopeResolver->processExprNode($stmt, $expr->getName(), $scope, $storage, $nodeCallback, ExpressionContext::createDeep($context->shouldResolveTemplateArguments())); $scope = $nameResult->getScope(); $hasYield = $hasYield || $nameResult->hasYield(); $throwPoints = array_merge($throwPoints, $nameResult->getThrowPoints()); diff --git a/src/Analyser/ExpressionContext.php b/src/Analyser/ExpressionContext.php index 7716b4ea6af..6955241a9a5 100644 --- a/src/Analyser/ExpressionContext.php +++ b/src/Analyser/ExpressionContext.php @@ -18,18 +18,19 @@ private function __construct( private bool $inThrow = false, private ?Type $inAssignRightSideType = null, private ?Type $inAssignRightSideNativeType = null, + private bool $resolveTemplateArguments = true, ) { } - public static function createTopLevel(): self + public static function createTopLevel(bool $resolveTemplateArguments = true): self { - return new self(isDeep: false, inAssignRightSideVariableName: null, inAssignRightSideExpr: null); + return new self(isDeep: false, inAssignRightSideVariableName: null, inAssignRightSideExpr: null, resolveTemplateArguments: $resolveTemplateArguments); } - public static function createDeep(): self + public static function createDeep(bool $resolveTemplateArguments = true): self { - return new self(isDeep: true, inAssignRightSideVariableName: null, inAssignRightSideExpr: null); + return new self(isDeep: true, inAssignRightSideVariableName: null, inAssignRightSideExpr: null, resolveTemplateArguments: $resolveTemplateArguments); } public function enterDeep(): self @@ -38,7 +39,7 @@ public function enterDeep(): self return $this; } - return new self(true, $this->inAssignRightSideVariableName, $this->inAssignRightSideExpr, $this->inThrow, $this->inAssignRightSideType, $this->inAssignRightSideNativeType); + return new self(true, $this->inAssignRightSideVariableName, $this->inAssignRightSideExpr, $this->inThrow, $this->inAssignRightSideType, $this->inAssignRightSideNativeType, $this->resolveTemplateArguments); } public function isDeep(): bool @@ -46,9 +47,23 @@ public function isDeep(): bool return $this->isDeep; } + public function shouldResolveTemplateArguments(): bool + { + return $this->resolveTemplateArguments; + } + + public function withoutTemplateArgumentResolution(): self + { + if (!$this->resolveTemplateArguments) { + return $this; + } + + return new self($this->isDeep, $this->inAssignRightSideVariableName, $this->inAssignRightSideExpr, $this->inThrow, $this->inAssignRightSideType, $this->inAssignRightSideNativeType, false); + } + public function enterThrow(): self { - return new self($this->isDeep, $this->inAssignRightSideVariableName, $this->inAssignRightSideExpr, true, $this->inAssignRightSideType, $this->inAssignRightSideNativeType); + return new self($this->isDeep, $this->inAssignRightSideVariableName, $this->inAssignRightSideExpr, true, $this->inAssignRightSideType, $this->inAssignRightSideNativeType, $this->resolveTemplateArguments); } public function isInThrow(): bool @@ -58,7 +73,7 @@ public function isInThrow(): bool public function enterRightSideAssign(string $variableName, Expr $expr): self { - return new self($this->isDeep, $variableName, $expr, $this->inThrow); + return new self($this->isDeep, $variableName, $expr, $this->inThrow, resolveTemplateArguments: $this->resolveTemplateArguments); } public function getInAssignRightSideVariableName(): ?string @@ -87,6 +102,7 @@ public function enterAssignRightSideCallArgs(ParametersAcceptor $acceptor): self $this->inThrow, TemplateTypeHelper::resolveToBounds($acceptor->getReturnType()), TemplateTypeHelper::resolveToBounds($acceptor instanceof ExtendedParametersAcceptor ? $acceptor->getNativeReturnType() : $acceptor->getReturnType()), + $this->resolveTemplateArguments, ); } diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index a4b85ccbc35..eb17f971f87 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -491,7 +491,7 @@ private function resolveBackwardGotoScope( $bodyScope, $tempStorage, new NoopNodeCallback(), - $context, + $context->withoutTemplateArgumentResolution(), ); $gotoScope = null; @@ -674,8 +674,7 @@ private function doProcessStmtNodes( $shouldCheckLastStatement && $stmtCount > 0 && $this->unresolvedTemplateArguments - && !$nodeCallback instanceof RecordingNodeCallback - && !$nodeCallback instanceof NoopNodeCallback + && $context->shouldResolveTemplateArguments() ) { return $this->processBodyStmtNodesTwoPass($parentNode, $stmts, $scope, $storage, $nodeCallback, $context); } @@ -870,12 +869,13 @@ private function processBodyStmtNodesTwoPass( $state = new StatementListWalkState($scope); /** @var list $entries the state and recording offset before each statement, plus the final ones */ $entries = []; + $observationContext = $context->withoutTemplateArgumentResolution(); $suspendedGatherers = $this->nodeGatherers; $this->nodeGatherers = []; try { foreach ($stmts as $i => $stmt) { $entries[$i] = [clone $state, $recording->count()]; - $this->processStatementStep($parentNode, $stmts, $i, $stmt, $state, $storage, $recording, $context, true); + $this->processStatementStep($parentNode, $stmts, $i, $stmt, $state, $storage, $recording, $observationContext, true); } } finally { $this->nodeGatherers = $suspendedGatherers; @@ -1347,7 +1347,7 @@ public function processExprOnDemand(Expr $expr, MutatingScope $scope, Expression $scope, $storage, new NoopNodeCallback(), - ExpressionContext::createTopLevel(), + ExpressionContext::createTopLevel(resolveTemplateArguments: false), ); } finally { $scope->popExpressionResultStorage(); @@ -2047,7 +2047,7 @@ private function processClosureNodeInternal( if (count($byRefUses) === 0) { $this->pushNodeGatherer($closureStmtsGatherer); try { - $statementResult = $this->processStmtNodesInternal($expr, $expr->stmts, $closureScope, $storage, $nodeCallback, StatementContext::createTopLevel()); + $statementResult = $this->processStmtNodesInternal($expr, $expr->stmts, $closureScope, $storage, $nodeCallback, StatementContext::createTopLevel($context->shouldResolveTemplateArguments())); } finally { $this->popNodeGatherer(); } @@ -2092,7 +2092,7 @@ private function processClosureNodeInternal( // loops walk single-pass here and only the final walk below (top-level) // runs their full convergence - otherwise every closure-convergence // pass would re-converge every inner loop from scratch - $intermediaryClosureScopeResult = $this->processStmtNodesInternal($expr, $expr->stmts, $closureScope, $storage, $bodyRecording, StatementContext::createDeep()); + $intermediaryClosureScopeResult = $this->processStmtNodesInternal($expr, $expr->stmts, $closureScope, $storage, $bodyRecording, StatementContext::createDeep(resolveTemplateArguments: false)); // the candidate to replace the final walk when this pass's entry // turns out to be the fixpoint if ($bodyRecording instanceof RecordingNodeCallback) { @@ -2146,7 +2146,7 @@ private function processClosureNodeInternal( $this->replayRecording($replayBodyRecording, $nodeCallback, $originalStorage, $closureScope); $statementResult = $replayPassResult; } else { - $statementResult = $this->processStmtNodesInternal($expr, $expr->stmts, $closureScope, $storage, $nodeCallback, StatementContext::createTopLevel()); + $statementResult = $this->processStmtNodesInternal($expr, $expr->stmts, $closureScope, $storage, $nodeCallback, StatementContext::createTopLevel($context->shouldResolveTemplateArguments())); } } finally { $this->popNodeGatherer(); @@ -2255,8 +2255,10 @@ public function processArrowFunctionNode( callable $nodeCallback, ?Type $passedToType, ?Type $nativePassedToType = null, + ?ExpressionContext $context = null, ): ProcessArrowFunctionResult { + $context ??= ExpressionContext::createTopLevel(); foreach ($expr->params as $param) { $this->processParamNode($stmt, $param, $scope, $storage, $nodeCallback); } @@ -2305,7 +2307,7 @@ public function processArrowFunctionNode( $this->pushNodeGatherer($arrowFunctionStmtsGatherer); try { - $exprResult = $this->processExprNode($stmt, $expr->expr, $arrowFunctionScope, $storage, $nodeCallback, ExpressionContext::createTopLevel()); + $exprResult = $this->processExprNode($stmt, $expr->expr, $arrowFunctionScope, $storage, $nodeCallback, ExpressionContext::createTopLevel($context->shouldResolveTemplateArguments())); } finally { $this->popNodeGatherer(); } @@ -3025,7 +3027,7 @@ public function processArgs( } } - $arrowFunctionResult = $this->processArrowFunctionNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $parameterType, $parameterNativeType); + $arrowFunctionResult = $this->processArrowFunctionNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $parameterType, $parameterNativeType, $context); $arrowFunctionExprResult = $arrowFunctionResult->getExpressionResult(); if ($this->callCallbackImmediately($parameter, $parameterType, $calleeReflection)) { $throwPoints = array_merge($throwPoints, array_map(static fn (InternalThrowPoint $throwPoint) => $throwPoint->isExplicit() ? InternalThrowPoint::createExplicit($scope, $throwPoint->getType(), $arg->value, $throwPoint->canContainAnyThrowable()) : InternalThrowPoint::createImplicit($scope, $arg->value), $arrowFunctionExprResult->getThrowPoints())); @@ -3458,7 +3460,7 @@ public function processDroppedArgs( continue; } - $this->processExprNode($stmt, $originalArg->value, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); + $this->processExprNode($stmt, $originalArg->value, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()->withoutTemplateArgumentResolution()); } } diff --git a/src/Analyser/StatementContext.php b/src/Analyser/StatementContext.php index fb2893b584e..ae36c10a31c 100644 --- a/src/Analyser/StatementContext.php +++ b/src/Analyser/StatementContext.php @@ -16,6 +16,7 @@ final class StatementContext private function __construct( private bool $isTopLevel, private int $foreachUnrollFactor = 1, + private bool $resolveTemplateArguments = true, ) { } @@ -23,17 +24,17 @@ private function __construct( /** * @api */ - public static function createTopLevel(): self + public static function createTopLevel(bool $resolveTemplateArguments = true): self { - return new self(true); + return new self(true, resolveTemplateArguments: $resolveTemplateArguments); } /** * @api */ - public static function createDeep(): self + public static function createDeep(bool $resolveTemplateArguments = true): self { - return new self(false); + return new self(false, resolveTemplateArguments: $resolveTemplateArguments); } public function isTopLevel(): bool @@ -46,10 +47,24 @@ public function getForeachUnrollFactor(): int return $this->foreachUnrollFactor; } + public function shouldResolveTemplateArguments(): bool + { + return $this->resolveTemplateArguments; + } + + public function withoutTemplateArgumentResolution(): self + { + if (!$this->resolveTemplateArguments) { + return $this; + } + + return new self($this->isTopLevel, $this->foreachUnrollFactor, false); + } + public function enterDeep(): self { if ($this->isTopLevel) { - return new self(false, $this->foreachUnrollFactor); + return new self(false, $this->foreachUnrollFactor, $this->resolveTemplateArguments); } return $this; @@ -57,7 +72,7 @@ public function enterDeep(): self public function enterUnrolledForeach(int $totalKeys): self { - return new self($this->isTopLevel, $this->foreachUnrollFactor * $totalKeys); + return new self($this->isTopLevel, $this->foreachUnrollFactor * $totalKeys, $this->resolveTemplateArguments); } } diff --git a/src/Analyser/StmtHandler/BreakContinueHandler.php b/src/Analyser/StmtHandler/BreakContinueHandler.php index f8ce30f0be3..b7eef408186 100644 --- a/src/Analyser/StmtHandler/BreakContinueHandler.php +++ b/src/Analyser/StmtHandler/BreakContinueHandler.php @@ -37,7 +37,7 @@ public function processStmt( ): InternalStatementResult { if ($stmt->num !== null) { - $result = $nodeScopeResolver->processExprNode($stmt, $stmt->num, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $result = $nodeScopeResolver->processExprNode($stmt, $stmt->num, $scope, $storage, $nodeCallback, ExpressionContext::createDeep($context->shouldResolveTemplateArguments())); $scope = $result->getScope(); $hasYield = $result->hasYield(); $throwPoints = $result->getThrowPoints(); diff --git a/src/Analyser/StmtHandler/ClassConstHandler.php b/src/Analyser/StmtHandler/ClassConstHandler.php index fd6f1977582..1db93fad6ea 100644 --- a/src/Analyser/StmtHandler/ClassConstHandler.php +++ b/src/Analyser/StmtHandler/ClassConstHandler.php @@ -42,7 +42,7 @@ public function processStmt( $impurePoints = []; $nodeScopeResolver->processAttributeGroups($stmt, $stmt->attrGroups, $scope, $storage, $nodeCallback); foreach ($stmt->consts as $const) { - $constResult = $nodeScopeResolver->processExprNode($stmt, $const->value, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $constResult = $nodeScopeResolver->processExprNode($stmt, $const->value, $scope, $storage, $nodeCallback, ExpressionContext::createDeep($context->shouldResolveTemplateArguments())); // the constant's callback fires after its value was processed, so // rule-side asks about the value answer from the storage $nodeScopeResolver->callNodeCallback($nodeCallback, $const, $scope, $storage); diff --git a/src/Analyser/StmtHandler/ClassLikeHandler.php b/src/Analyser/StmtHandler/ClassLikeHandler.php index c9f099531e2..b86547f57e3 100644 --- a/src/Analyser/StmtHandler/ClassLikeHandler.php +++ b/src/Analyser/StmtHandler/ClassLikeHandler.php @@ -124,7 +124,9 @@ public function processStmt( return [!$a->isStatic(), $a->name->toLowerString() !== '__construct'] <=> [!$b->isStatic(), $b->name->toLowerString() !== '__construct']; }); - $nodeScopeResolver->processStmtNodesInternal($stmt, $classLikeStatements, $classScope, $storage, $classStatementsGatherer, $context); + // Class members have their own inference context, including when the class + // declaration is visited during an enclosing body's observation pass. + $nodeScopeResolver->processStmtNodesInternal($stmt, $classLikeStatements, $classScope, $storage, $classStatementsGatherer, StatementContext::createTopLevel()); $nodeScopeResolver->callNodeCallback($nodeCallback, new ClassPropertiesNode($stmt, $nodeScopeResolver->getReadWritePropertiesExtensions(), $classStatementsGatherer->getProperties(), $classStatementsGatherer->getPropertyUsages(), $classStatementsGatherer->getMethodCalls(), $classStatementsGatherer->getReturnStatementsNodes(), $classStatementsGatherer->getPropertyAssigns(), $classReflection), $classScope, $storage); $nodeScopeResolver->callNodeCallback($nodeCallback, new ClassMethodsNode($stmt, $classStatementsGatherer->getMethods(), $classStatementsGatherer->getMethodCalls(), $classReflection), $classScope, $storage); $nodeScopeResolver->callNodeCallback($nodeCallback, new ClassConstantsNode($stmt, $classStatementsGatherer->getConstants(), $classStatementsGatherer->getConstantFetches(), $classReflection), $classScope, $storage); diff --git a/src/Analyser/StmtHandler/ClassMethodHandler.php b/src/Analyser/StmtHandler/ClassMethodHandler.php index 1a333a701f9..873881047de 100644 --- a/src/Analyser/StmtHandler/ClassMethodHandler.php +++ b/src/Analyser/StmtHandler/ClassMethodHandler.php @@ -217,7 +217,7 @@ public function processStmt( $gatheredReturnStatements[] = new ReturnStatement($scope, $node); }); try { - $statementResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $methodScope, $bodyStorage, $nodeCallback, StatementContext::createTopLevel())->toPublic(); + $statementResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $methodScope, $bodyStorage, $nodeCallback, StatementContext::createTopLevel($context->shouldResolveTemplateArguments()))->toPublic(); } finally { $nodeScopeResolver->popNodeGatherer(); } diff --git a/src/Analyser/StmtHandler/ConstHandler.php b/src/Analyser/StmtHandler/ConstHandler.php index 9dd7404d9ee..2d8e9084077 100644 --- a/src/Analyser/StmtHandler/ConstHandler.php +++ b/src/Analyser/StmtHandler/ConstHandler.php @@ -40,7 +40,7 @@ public function processStmt( $entryScope = $scope; $impurePoints = []; foreach ($stmt->consts as $const) { - $constResult = $nodeScopeResolver->processExprNode($stmt, $const->value, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $constResult = $nodeScopeResolver->processExprNode($stmt, $const->value, $scope, $storage, $nodeCallback, ExpressionContext::createDeep($context->shouldResolveTemplateArguments())); // the constant's callback fires after its value was processed, so // rule-side asks about the value answer from the storage $nodeScopeResolver->callNodeCallback($nodeCallback, $const, $scope, $storage); diff --git a/src/Analyser/StmtHandler/DeclareHandler.php b/src/Analyser/StmtHandler/DeclareHandler.php index c1a1ee44677..4cc30c5a0df 100644 --- a/src/Analyser/StmtHandler/DeclareHandler.php +++ b/src/Analyser/StmtHandler/DeclareHandler.php @@ -44,7 +44,7 @@ public function processStmt( $nodeScopeResolver->callNodeCallback($nodeCallback, $declare, $scope, $storage); // the value is a constant scalar - process it so its result is stored // before the callback fires on it, like every other expression node - $nodeScopeResolver->processExprNode($stmt, $declare->value, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $nodeScopeResolver->processExprNode($stmt, $declare->value, $scope, $storage, $nodeCallback, ExpressionContext::createDeep($context->shouldResolveTemplateArguments())); if ( $declare->key->name !== 'strict_types' || !($declare->value instanceof Int_) diff --git a/src/Analyser/StmtHandler/DoWhileHandler.php b/src/Analyser/StmtHandler/DoWhileHandler.php index eda9d52dc32..b6103523522 100644 --- a/src/Analyser/StmtHandler/DoWhileHandler.php +++ b/src/Analyser/StmtHandler/DoWhileHandler.php @@ -70,7 +70,7 @@ public function processStmt( $bodyRecording = $bodyIsReplayable ? new RecordingNodeCallback() : new NoopNodeCallback(); $scope->pushExpressionResultStorage($storage); try { - $bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $bodyRecording, $context->enterDeep())->filterOutLoopExitPoints(); + $bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $bodyRecording, $context->enterDeep()->withoutTemplateArgumentResolution())->filterOutLoopExitPoints(); $alwaysTerminating = $bodyScopeResult->isAlwaysTerminating(); $bodyScope = $bodyScopeResult->getScope(); foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { @@ -87,7 +87,7 @@ public function processStmt( $replayPassStorage = $storage; $replayPassResult = $bodyScopeResult; } - $bodyScope = $nodeScopeResolver->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep())->getTruthyScope(); + $bodyScope = $nodeScopeResolver->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep(resolveTemplateArguments: false))->getTruthyScope(); } finally { $scope->popExpressionResultStorage(); } @@ -129,7 +129,7 @@ public function processStmt( // scope - the previous scope-based read here was a guaranteed storage // miss (the condition was only ever stored into discarded convergence // duplicates) that re-priced the condition on demand before this walk - $condResult = $nodeScopeResolver->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $condResult = $nodeScopeResolver->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep($context->shouldResolveTemplateArguments())); $alwaysIterates = false; if ($context->isTopLevel()) { diff --git a/src/Analyser/StmtHandler/EchoHandler.php b/src/Analyser/StmtHandler/EchoHandler.php index 0d69c76e86d..107c4e52581 100644 --- a/src/Analyser/StmtHandler/EchoHandler.php +++ b/src/Analyser/StmtHandler/EchoHandler.php @@ -47,7 +47,7 @@ public function processStmt( $impurePoints = []; $isAlwaysTerminating = false; foreach ($stmt->exprs as $echoExpr) { - $result = $nodeScopeResolver->processExprNode($stmt, $echoExpr, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $result = $nodeScopeResolver->processExprNode($stmt, $echoExpr, $scope, $storage, $nodeCallback, ExpressionContext::createDeep($context->shouldResolveTemplateArguments())); $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($echoExpr, $scope, $result); diff --git a/src/Analyser/StmtHandler/EnumCaseHandler.php b/src/Analyser/StmtHandler/EnumCaseHandler.php index bec51099998..bbaf1d1beee 100644 --- a/src/Analyser/StmtHandler/EnumCaseHandler.php +++ b/src/Analyser/StmtHandler/EnumCaseHandler.php @@ -37,7 +37,7 @@ public function processStmt( $nodeScopeResolver->processAttributeGroups($stmt, $stmt->attrGroups, $scope, $storage, $nodeCallback); $impurePoints = []; if ($stmt->expr !== null) { - $exprResult = $nodeScopeResolver->processExprNode($stmt, $stmt->expr, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $exprResult = $nodeScopeResolver->processExprNode($stmt, $stmt->expr, $scope, $storage, $nodeCallback, ExpressionContext::createDeep($context->shouldResolveTemplateArguments())); $impurePoints = $exprResult->getImpurePoints(); } diff --git a/src/Analyser/StmtHandler/ExpressionHandler.php b/src/Analyser/StmtHandler/ExpressionHandler.php index 5697e64f47a..3aeb14fb24f 100644 --- a/src/Analyser/StmtHandler/ExpressionHandler.php +++ b/src/Analyser/StmtHandler/ExpressionHandler.php @@ -65,7 +65,7 @@ public function processStmt( $hasAssign = true; }); try { - $result = $nodeScopeResolver->processExprNode($stmt, $stmt->expr, $scope, $storage, $nodeCallback, ExpressionContext::createTopLevel()); + $result = $nodeScopeResolver->processExprNode($stmt, $stmt->expr, $scope, $storage, $nodeCallback, ExpressionContext::createTopLevel($context->shouldResolveTemplateArguments())); if ($stmt->expr instanceof Expr\Throw_) { // the @var-changed-type node fires now that the thrown expression is stored $result = $result->withScope($result->getScope()->addTemplateArgumentConstraints($nodeScopeResolver->emitVarTagChangedNode($preAnnotationScope, $storage, $stmt, $stmt->expr->expr, $nodeCallback))); diff --git a/src/Analyser/StmtHandler/ForHandler.php b/src/Analyser/StmtHandler/ForHandler.php index 630253a8d84..8269128e9f5 100644 --- a/src/Analyser/StmtHandler/ForHandler.php +++ b/src/Analyser/StmtHandler/ForHandler.php @@ -127,7 +127,7 @@ public function processStmt( $throwPoints = []; $impurePoints = []; foreach ($stmt->init as $initExpr) { - $initResult = $nodeScopeResolver->processExprNode($stmt, $initExpr, $initScope, $storage, $nodeCallback, ExpressionContext::createTopLevel()); + $initResult = $nodeScopeResolver->processExprNode($stmt, $initExpr, $initScope, $storage, $nodeCallback, ExpressionContext::createTopLevel($context->shouldResolveTemplateArguments())); $initScope = $initResult->getScope(); $hasYield = $hasYield || $initResult->hasYield(); $throwPoints = array_merge($throwPoints, $initResult->getThrowPoints()); @@ -144,7 +144,7 @@ public function processStmt( $scope->pushExpressionResultStorage($storage); try { foreach ($stmt->cond as $condExpr) { - $condResult = $nodeScopeResolver->processExprNode($stmt, $condExpr, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep()); + $condResult = $nodeScopeResolver->processExprNode($stmt, $condExpr, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep(resolveTemplateArguments: false)); $initScope = $condResult->getScope(); // only the last condition expression is relevant whether the loop continues @@ -181,16 +181,16 @@ public function processStmt( $scope->pushExpressionResultStorage($storage); try { if ($lastCondExpr !== null) { - $bodyScope = $nodeScopeResolver->processExprNode($stmt, $lastCondExpr, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep())->getTruthyScope(); + $bodyScope = $nodeScopeResolver->processExprNode($stmt, $lastCondExpr, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep(resolveTemplateArguments: false))->getTruthyScope(); } - $bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); + $bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep()->withoutTemplateArgumentResolution())->filterOutLoopExitPoints(); $bodyScope = $bodyScopeResult->getScope(); foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); } foreach ($stmt->loop as $loopExpr) { - $exprResult = $nodeScopeResolver->processExprNode($stmt, $loopExpr, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createTopLevel()); + $exprResult = $nodeScopeResolver->processExprNode($stmt, $loopExpr, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createTopLevel(resolveTemplateArguments: false)); $bodyScope = $exprResult->getScope(); $hasYield = $hasYield || $exprResult->hasYield(); $throwPoints = array_merge($throwPoints, $exprResult->getThrowPoints()); @@ -220,7 +220,7 @@ public function processStmt( // its result - the previous scope-based read was a guaranteed // storage miss (the condition was only stored into discarded // convergence duplicates) that re-priced it on demand - $condResult = $nodeScopeResolver->processExprNode($stmt, $lastCondExpr, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $condResult = $nodeScopeResolver->processExprNode($stmt, $lastCondExpr, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep($context->shouldResolveTemplateArguments())); $alwaysIterates = $alwaysIterates->and($condResult->getType()->toBoolean()->isTrue()); $bodyScope = $condResult->getTruthyScope(); $bodyScope = $this->inferForLoopExpressions($nodeScopeResolver, $stmt, $lastCondExpr, $bodyScope, $storage); @@ -234,7 +234,7 @@ public function processStmt( $loopScope = $finalScope; foreach ($stmt->loop as $loopExpr) { - $loopScope = $nodeScopeResolver->processExprNode($stmt, $loopExpr, $loopScope, $storage, $nodeCallback, ExpressionContext::createTopLevel())->getScope(); + $loopScope = $nodeScopeResolver->processExprNode($stmt, $loopExpr, $loopScope, $storage, $nodeCallback, ExpressionContext::createTopLevel($context->shouldResolveTemplateArguments()))->getScope(); } $finalScope = $finalScope->generalizeWith($loopScope); diff --git a/src/Analyser/StmtHandler/ForeachHandler.php b/src/Analyser/StmtHandler/ForeachHandler.php index 7fa71fd729c..25b664bdd62 100644 --- a/src/Analyser/StmtHandler/ForeachHandler.php +++ b/src/Analyser/StmtHandler/ForeachHandler.php @@ -101,7 +101,7 @@ public function processStmt( if ($stmt->expr instanceof Variable && is_string($stmt->expr->name)) { $scope = $this->varAnnotationProcessor->processVarAnnotation($scope, [$stmt->expr->name], $stmt); } - $condResult = $nodeScopeResolver->processExprNode($stmt, $stmt->expr, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $condResult = $nodeScopeResolver->processExprNode($stmt, $stmt->expr, $scope, $storage, $nodeCallback, ExpressionContext::createDeep($context->shouldResolveTemplateArguments())); $nodeScopeResolver->callNodeCallback($nodeCallback, $stmt, $entryScope, $storage); $throwPoints = $condResult->getThrowPoints(); $impurePoints = $condResult->getImpurePoints(); @@ -228,7 +228,7 @@ static function () use ($condResult, $emptyArrayType): Type { $scope->pushExpressionResultStorage($storage); try { $bodyScope = $this->enterForeach($nodeScopeResolver, $bodyScope, $storage, $originalScope, $stmt, $foreachIterateeType, $foreachNativeIterateeType, $nodeCallback); - $bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $bodyRecording, $context->enterDeep())->filterOutLoopExitPoints(); + $bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $bodyRecording, $context->enterDeep()->withoutTemplateArgumentResolution())->filterOutLoopExitPoints(); $bodyScope = $bodyScopeResult->getScope(); foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); @@ -789,7 +789,7 @@ private function tryProcessUnrolledConstantArrayForeach( $iterStorage = $originalStorage->duplicate(); $iterBodyScope = $loopScope->mergeWith($endScope); $iterBodyScope = $this->enterForeach($nodeScopeResolver, $iterBodyScope, $iterStorage, $originalScope, $stmt, $iterateeType, $nativeIterateeType, new NoopNodeCallback()); - $iterBodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $iterBodyScope, $iterStorage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); + $iterBodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $iterBodyScope, $iterStorage, new NoopNodeCallback(), $context->enterDeep()->withoutTemplateArgumentResolution())->filterOutLoopExitPoints(); $loopScope = $iterBodyScopeResult->getScope(); foreach ($iterBodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { $loopScope = $loopScope->mergeWith($continueExitPoint->getScope()); diff --git a/src/Analyser/StmtHandler/FunctionHandler.php b/src/Analyser/StmtHandler/FunctionHandler.php index b5684d84c60..a0a141127f3 100644 --- a/src/Analyser/StmtHandler/FunctionHandler.php +++ b/src/Analyser/StmtHandler/FunctionHandler.php @@ -136,7 +136,7 @@ public function processStmt( $gatheredReturnStatements[] = new ReturnStatement($scope, $node); }); try { - $statementResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $functionScope, $bodyStorage, $nodeCallback, StatementContext::createTopLevel())->toPublic(); + $statementResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $functionScope, $bodyStorage, $nodeCallback, StatementContext::createTopLevel($context->shouldResolveTemplateArguments()))->toPublic(); } finally { $nodeScopeResolver->popNodeGatherer(); } diff --git a/src/Analyser/StmtHandler/GlobalHandler.php b/src/Analyser/StmtHandler/GlobalHandler.php index 96a45ae59d8..665430b4849 100644 --- a/src/Analyser/StmtHandler/GlobalHandler.php +++ b/src/Analyser/StmtHandler/GlobalHandler.php @@ -77,7 +77,7 @@ public function processStmt( throw new ShouldNotHappenException(); } $scope = $nodeScopeResolver->lookForSetAllowedUndefinedExpressions($scope, $var); - $varResult = $nodeScopeResolver->processExprNode($stmt, $var, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $varResult = $nodeScopeResolver->processExprNode($stmt, $var, $scope, $storage, $nodeCallback, ExpressionContext::createDeep($context->shouldResolveTemplateArguments())); $impurePoints = array_merge($impurePoints, $varResult->getImpurePoints()); $scope = $nodeScopeResolver->lookForUnsetAllowedUndefinedExpressions($scope, $var); diff --git a/src/Analyser/StmtHandler/IfHandler.php b/src/Analyser/StmtHandler/IfHandler.php index 32b1386c391..784f2880769 100644 --- a/src/Analyser/StmtHandler/IfHandler.php +++ b/src/Analyser/StmtHandler/IfHandler.php @@ -38,7 +38,7 @@ public function processStmt( ): InternalStatementResult { $entryScope = $scope; - $condResult = $nodeScopeResolver->processExprNode($stmt, $stmt->cond, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $condResult = $nodeScopeResolver->processExprNode($stmt, $stmt->cond, $scope, $storage, $nodeCallback, ExpressionContext::createDeep($context->shouldResolveTemplateArguments())); $nodeScopeResolver->callNodeCallback($nodeCallback, $stmt, $entryScope, $storage); $conditionType = ($nodeScopeResolver->shouldTreatPhpDocTypesAsCertain() ? $condResult->getType() : $condResult->getNativeType())->toBoolean(); $ifAlwaysTrue = $conditionType->isTrue()->yes(); @@ -74,7 +74,7 @@ public function processStmt( $condScope = $scope; foreach ($stmt->elseifs as $elseif) { - $condResult = $nodeScopeResolver->processExprNode($stmt, $elseif->cond, $condScope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $condResult = $nodeScopeResolver->processExprNode($stmt, $elseif->cond, $condScope, $storage, $nodeCallback, ExpressionContext::createDeep($context->shouldResolveTemplateArguments())); $nodeScopeResolver->callNodeCallback($nodeCallback, $elseif, $scope, $storage); $elseIfConditionType = ($nodeScopeResolver->shouldTreatPhpDocTypesAsCertain() ? $condResult->getType() : $condResult->getNativeType())->toBoolean(); $throwPoints = array_merge($throwPoints, $condResult->getThrowPoints()); diff --git a/src/Analyser/StmtHandler/PropertyHandler.php b/src/Analyser/StmtHandler/PropertyHandler.php index 7df721de11f..7489cf17ca4 100644 --- a/src/Analyser/StmtHandler/PropertyHandler.php +++ b/src/Analyser/StmtHandler/PropertyHandler.php @@ -60,7 +60,7 @@ public function processStmt( foreach ($stmt->props as $prop) { $nodeScopeResolver->callNodeCallback($nodeCallback, $prop, $scope, $storage); if ($prop->default !== null) { - $nodeScopeResolver->processExprNode($stmt, $prop->default, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $nodeScopeResolver->processExprNode($stmt, $prop->default, $scope, $storage, $nodeCallback, ExpressionContext::createDeep($context->shouldResolveTemplateArguments())); } if (!$scope->isInClass()) { diff --git a/src/Analyser/StmtHandler/ReturnHandler.php b/src/Analyser/StmtHandler/ReturnHandler.php index 5b21f5227ec..c73ac98e59b 100644 --- a/src/Analyser/StmtHandler/ReturnHandler.php +++ b/src/Analyser/StmtHandler/ReturnHandler.php @@ -38,7 +38,7 @@ public function processStmt( $stmtScope = $nodeScopeResolver->processStmtVarAnnotation($scope, $storage, $stmt, $stmt->expr, $nodeCallback); if ($stmt->expr !== null) { - $result = $nodeScopeResolver->processExprNode($stmt, $stmt->expr, $stmtScope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $result = $nodeScopeResolver->processExprNode($stmt, $stmt->expr, $stmtScope, $storage, $nodeCallback, ExpressionContext::createDeep($context->shouldResolveTemplateArguments())); // the @var-changed-type node fires now that the expression is stored // on the scope BEFORE the @var tag re-typed the expression, so the rule // compares the tag against the expression's walked type diff --git a/src/Analyser/StmtHandler/StaticVariableHandler.php b/src/Analyser/StmtHandler/StaticVariableHandler.php index e35edc409c0..260efdc2b71 100644 --- a/src/Analyser/StmtHandler/StaticVariableHandler.php +++ b/src/Analyser/StmtHandler/StaticVariableHandler.php @@ -64,12 +64,12 @@ public function processStmt( } if ($var->default !== null) { - $defaultExprResult = $nodeScopeResolver->processExprNode($stmt, $var->default, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $defaultExprResult = $nodeScopeResolver->processExprNode($stmt, $var->default, $scope, $storage, $nodeCallback, ExpressionContext::createDeep($context->shouldResolveTemplateArguments())); $impurePoints = array_merge($impurePoints, $defaultExprResult->getImpurePoints()); } $scope = $scope->enterExpressionAssign($var->var); - $varResult = $nodeScopeResolver->processExprNode($stmt, $var->var, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $varResult = $nodeScopeResolver->processExprNode($stmt, $var->var, $scope, $storage, $nodeCallback, ExpressionContext::createDeep($context->shouldResolveTemplateArguments())); $impurePoints = array_merge($impurePoints, $varResult->getImpurePoints()); $scope = $scope->exitExpressionAssign($var->var); diff --git a/src/Analyser/StmtHandler/SwitchHandler.php b/src/Analyser/StmtHandler/SwitchHandler.php index 8375c8c3de8..c9ea11a2421 100644 --- a/src/Analyser/StmtHandler/SwitchHandler.php +++ b/src/Analyser/StmtHandler/SwitchHandler.php @@ -50,7 +50,7 @@ public function processStmt( ): InternalStatementResult { $entryScope = $scope; - $condResult = $nodeScopeResolver->processExprNode($stmt, $stmt->cond, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $condResult = $nodeScopeResolver->processExprNode($stmt, $stmt->cond, $scope, $storage, $nodeCallback, ExpressionContext::createDeep($context->shouldResolveTemplateArguments())); $scope = $condResult->getScope(); $scopeForBranches = $scope; $finalScope = null; @@ -75,7 +75,7 @@ public function processStmt( if ($caseNode->cond !== null) { $condExpr = new BinaryOp\Equal($stmt->cond, $caseNode->cond); $fullCondExpr = $fullCondExpr === null ? $condExpr : new BooleanOr($fullCondExpr, $condExpr); - $caseResult = $nodeScopeResolver->processExprNode($stmt, $caseNode->cond, $scopeForBranches, $storage, $nodeCallback, ExpressionContext::createDeep()); + $caseResult = $nodeScopeResolver->processExprNode($stmt, $caseNode->cond, $scopeForBranches, $storage, $nodeCallback, ExpressionContext::createDeep($context->shouldResolveTemplateArguments())); $scopeForBranches = $caseResult->getScope(); $hasYield = $hasYield || $caseResult->hasYield(); $throwPoints = array_merge($throwPoints, $caseResult->getThrowPoints()); diff --git a/src/Analyser/StmtHandler/UnsetHandler.php b/src/Analyser/StmtHandler/UnsetHandler.php index c7c03978a08..b404aada5b3 100644 --- a/src/Analyser/StmtHandler/UnsetHandler.php +++ b/src/Analyser/StmtHandler/UnsetHandler.php @@ -59,7 +59,7 @@ public function processStmt( $impurePoints = []; foreach ($stmt->vars as $var) { $scope = $nodeScopeResolver->lookForSetAllowedUndefinedExpressions($scope, $var); - $exprResult = $nodeScopeResolver->processExprNode($stmt, $var, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $exprResult = $nodeScopeResolver->processExprNode($stmt, $var, $scope, $storage, $nodeCallback, ExpressionContext::createDeep($context->shouldResolveTemplateArguments())); $scope = $exprResult->getScope(); $scope = $nodeScopeResolver->lookForUnsetAllowedUndefinedExpressions($scope, $var); $hasYield = $hasYield || $exprResult->hasYield(); @@ -70,7 +70,7 @@ public function processStmt( if (!$varType->isArray()->yes() && !(new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->no()) { $throwPoints = array_merge($throwPoints, $this->container->getByType(MethodThrowPointHelper::class)->getThrowPointsForCallOnType( $scope, - ExpressionContext::createDeep(), + ExpressionContext::createDeep($context->shouldResolveTemplateArguments()), $varType, new MethodCall(new TypeExpr($varType), 'offsetUnset'), )); diff --git a/src/Analyser/StmtHandler/WhileHandler.php b/src/Analyser/StmtHandler/WhileHandler.php index bf10d728520..5154e905962 100644 --- a/src/Analyser/StmtHandler/WhileHandler.php +++ b/src/Analyser/StmtHandler/WhileHandler.php @@ -49,7 +49,7 @@ public function processStmt( // read the pass's own results instead of re-pricing on demand $scope->pushExpressionResultStorage($storage); try { - $condResult = $nodeScopeResolver->processExprNode($stmt, $stmt->cond, $scope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep()); + $condResult = $nodeScopeResolver->processExprNode($stmt, $stmt->cond, $scope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep(resolveTemplateArguments: false)); $beforeCondBooleanType = ($nodeScopeResolver->shouldTreatPhpDocTypesAsCertain() ? $condResult->getType() : $condResult->getNativeType())->toBoolean(); $condScope = $condResult->getFalseyScope(); if (!$context->isTopLevel() && $beforeCondBooleanType->isFalse()->yes()) { @@ -95,9 +95,9 @@ public function processStmt( $bodyRecording = $bodyIsReplayable ? new RecordingNodeCallback() : new NoopNodeCallback(); $scope->pushExpressionResultStorage($storage); try { - $passCondResult = $nodeScopeResolver->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, $condRecording, ExpressionContext::createDeep()); + $passCondResult = $nodeScopeResolver->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, $condRecording, ExpressionContext::createDeep(resolveTemplateArguments: false)); $bodyScope = $passCondResult->getTruthyScope(); - $bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $bodyRecording, $context->enterDeep())->filterOutLoopExitPoints(); + $bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $bodyRecording, $context->enterDeep()->withoutTemplateArgumentResolution())->filterOutLoopExitPoints(); $bodyScope = $bodyScopeResult->getScope(); foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); @@ -146,7 +146,7 @@ public function processStmt( $bodyCondResult = $replayCondResult; $finalScopeResult = $replayPassResult; } else { - $bodyCondResult = $nodeScopeResolver->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $bodyCondResult = $nodeScopeResolver->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep($context->shouldResolveTemplateArguments())); // the While_ callback is deferred from processStmtNode(): it fires after // the condition's real walk stored its result, with the entry scope $nodeScopeResolver->callNodeCallback($nodeCallback, $stmt, $scope, $storage); diff --git a/tests/PHPStan/Analyser/Generics/AnalysisContextTest.php b/tests/PHPStan/Analyser/Generics/AnalysisContextTest.php new file mode 100644 index 00000000000..39782426d4e --- /dev/null +++ b/tests/PHPStan/Analyser/Generics/AnalysisContextTest.php @@ -0,0 +1,67 @@ + */ + public static function dataResolution(): iterable + { + yield [true]; + yield [false]; + } + + #[DataProvider('dataResolution')] + public function testRecordingDoesNotDetermineInference(bool $resolveTemplateArguments): void + { + $file = __DIR__ . '/data/explicit-analysis-context.php'; + $resolver = self::createNodeScopeResolver(); + $resolver->setAnalysedFiles([$file]); + $resolver->resetPerFileAnalysisState(); + $recording = new RecordingNodeCallback(); + TemplateArgumentStats::reset(); + TemplateArgumentStats::$enabled = true; + try { + $resolver->processStmtNodes( + new Node\Stmt\Nop(), + self::getParser()->parseFile($file), + self::createScope($file), + $recording, + StatementContext::createTopLevel($resolveTemplateArguments), + ); + $this->assertSame($resolveTemplateArguments ? 1 : 0, TemplateArgumentStats::getCounters()['bodiesWithSites']); + } finally { + TemplateArgumentStats::$enabled = false; + } + + if (!$resolveTemplateArguments) { + return; + } + + $returnTypes = []; + foreach ($recording->getPairs() as [$node, $scope]) { + if (!$node instanceof Node\Stmt\Return_) { + continue; + } + $returnTypes[] = $scope->getVariableType('ints'); + } + $this->assertCount(1, $returnTypes); + $this->assertTrue((new GenericObjectType('ArrayObject', [new IntegerType(), new IntegerType()]))->equals($returnTypes[0])); + } + + public static function getAdditionalConfigFiles(): array + { + return array_merge(parent::getAdditionalConfigFiles(), [__DIR__ . '/../../../../conf/bleedingEdge.neon']); + } + +} diff --git a/tests/PHPStan/Analyser/Generics/data/explicit-analysis-context.php b/tests/PHPStan/Analyser/Generics/data/explicit-analysis-context.php new file mode 100644 index 00000000000..7d5276bb88b --- /dev/null +++ b/tests/PHPStan/Analyser/Generics/data/explicit-analysis-context.php @@ -0,0 +1,10 @@ + */ +function createInts(): \ArrayObject +{ + $ints = new \ArrayObject([1]); + return $ints; +} From 186e52fdbdf216b08f989e9a12d7afc286ce5a1b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 7 Sep 2026 09:30:05 +0200 Subject: [PATCH 17/28] Keep template argument inference compatible with PHP 7.4 --- src/Analyser/ExprHandler/ArrowFunctionHandler.php | 2 +- src/Analyser/Generics/TemplateArgumentFrame.php | 2 +- src/Analyser/Generics/TemplateArgumentSolver.php | 2 +- src/Analyser/MutatingScope.php | 2 +- tests/PHPStan/Analyser/Generics/data/constraint-flow.php | 3 ++- tests/PHPStan/Rules/Classes/data/template-argument-arrow.php | 3 ++- 6 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/Analyser/ExprHandler/ArrowFunctionHandler.php b/src/Analyser/ExprHandler/ArrowFunctionHandler.php index fb027e55e66..8e9cbaac020 100644 --- a/src/Analyser/ExprHandler/ArrowFunctionHandler.php +++ b/src/Analyser/ExprHandler/ArrowFunctionHandler.php @@ -39,7 +39,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { - $arrowFunctionResult = $nodeScopeResolver->processArrowFunctionNode($stmt, $expr, $scope, $storage, $nodeCallback, null, context: $context); + $arrowFunctionResult = $nodeScopeResolver->processArrowFunctionNode($stmt, $expr, $scope, $storage, $nodeCallback, null, null, $context); $result = $arrowFunctionResult->getExpressionResult(); // A plain typeCallback recursing through getClosureType() would re-walk diff --git a/src/Analyser/Generics/TemplateArgumentFrame.php b/src/Analyser/Generics/TemplateArgumentFrame.php index 00f3d503e93..0e05d8e39ed 100644 --- a/src/Analyser/Generics/TemplateArgumentFrame.php +++ b/src/Analyser/Generics/TemplateArgumentFrame.php @@ -111,7 +111,7 @@ public function hasSiteAtOrAfter(int $statementIndex): bool */ public function resolveOrUnconstrained(Expr $site, TemplateType $template): Type { - return $this->resolve($site, $template->getName()) ?? self::resolveUnconstrained($site, $template, $this->resolve(...)); + return $this->resolve($site, $template->getName()) ?? self::resolveUnconstrained($site, $template, fn (Expr $site, string $templateName): ?Type => $this->resolve($site, $templateName)); } /** diff --git a/src/Analyser/Generics/TemplateArgumentSolver.php b/src/Analyser/Generics/TemplateArgumentSolver.php index 752ba95e6c5..f481df935ef 100644 --- a/src/Analyser/Generics/TemplateArgumentSolver.php +++ b/src/Analyser/Generics/TemplateArgumentSolver.php @@ -176,7 +176,7 @@ private function resolveObservation(array $observation): Type TemplateArgumentStats::increment('resolvedUnconstrained'); } - return TemplateArgumentFrame::resolveUnconstrained($observation['marker']->getSite(), $observation['marker']->getTemplate(), $this->resolve(...)); + return TemplateArgumentFrame::resolveUnconstrained($observation['marker']->getSite(), $observation['marker']->getTemplate(), fn (Expr $site, string $templateName): ?Type => $this->resolve($site, $templateName)); } if (TemplateArgumentStats::$enabled) { diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 38f922fa410..0baec480aa2 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -4379,7 +4379,7 @@ public function isInFirstLevelStatement(): bool public function mergeWith(?self $otherScope, bool $preserveVacuousConditionals = false): self { - return $this->mergeWithVariableState($otherScope, $preserveVacuousConditionals)->addTemplateArgumentConstraints($otherScope?->getTemplateArgumentConstraints()); + return $this->mergeWithVariableState($otherScope, $preserveVacuousConditionals)->addTemplateArgumentConstraints($otherScope !== null ? $otherScope->getTemplateArgumentConstraints() : null); } private function mergeWithVariableState(?self $otherScope, bool $preserveVacuousConditionals = false): self diff --git a/tests/PHPStan/Analyser/Generics/data/constraint-flow.php b/tests/PHPStan/Analyser/Generics/data/constraint-flow.php index 9afb2997100..4b40a58995b 100644 --- a/tests/PHPStan/Analyser/Generics/data/constraint-flow.php +++ b/tests/PHPStan/Analyser/Generics/data/constraint-flow.php @@ -1,4 +1,5 @@ -= 8.0 +declare(strict_types = 1); namespace TemplateArgumentConstraintFlow; diff --git a/tests/PHPStan/Rules/Classes/data/template-argument-arrow.php b/tests/PHPStan/Rules/Classes/data/template-argument-arrow.php index 35705cc59f6..f2358da48f2 100644 --- a/tests/PHPStan/Rules/Classes/data/template-argument-arrow.php +++ b/tests/PHPStan/Rules/Classes/data/template-argument-arrow.php @@ -1,4 +1,5 @@ -= 8.0 +declare(strict_types = 1); namespace TemplateArgumentArrow; From d59d8c461411095000c17df8695853449092fc5b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 7 Sep 2026 10:00:50 +0200 Subject: [PATCH 18/28] Respect template bounds when inferring from later arguments --- .../Generics/TemplateArgumentSolver.php | 9 +++- .../nsrt/template-argument-bounds.php | 48 +++++++++++++++++++ .../Arrays/OffsetAccessAssignmentRuleTest.php | 4 +- 3 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/template-argument-bounds.php diff --git a/src/Analyser/Generics/TemplateArgumentSolver.php b/src/Analyser/Generics/TemplateArgumentSolver.php index f481df935ef..5ab97358f17 100644 --- a/src/Analyser/Generics/TemplateArgumentSolver.php +++ b/src/Analyser/Generics/TemplateArgumentSolver.php @@ -108,11 +108,16 @@ private function substituteMarker(UnresolvedTemplateArgumentType $marker): Type private function resolveObservation(array $observation): Type { $initial = $observation['initial'] !== null ? $this->substituteResolutions($observation['initial']) : null; + $template = $observation['marker']->getTemplate(); $lowerBounds = []; foreach ($observation['lowerBounds'] as $lowerBound) { - $lowerBounds[] = $this->substituteResolutions($lowerBound); + $inferred = $template->inferTemplateTypes($this->substituteResolutions($lowerBound))->getType($template->getName()); + if ($inferred === null) { + continue; + } + $lowerBounds[] = $inferred; } - $templateVariance = $observation['marker']->getTemplate()->getVariance(); + $templateVariance = $template->getVariance(); // nothing inferred, or never (an empty array): every send accepts it $acceptsAnything = $initial === null || $initial instanceof NeverType; diff --git a/tests/PHPStan/Analyser/nsrt/template-argument-bounds.php b/tests/PHPStan/Analyser/nsrt/template-argument-bounds.php new file mode 100644 index 00000000000..974464323dc --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/template-argument-bounds.php @@ -0,0 +1,48 @@ += 8.0 +declare(strict_types = 1); + +namespace TemplateArgumentBounds; + +use SplObjectStorage; +use stdClass; +use function PHPStan\Testing\assertType; + +/** @template T of object */ +class Collection +{ + + /** @param T $item */ + public function add($item): void + { + } + +} + +function invalidOffset(): void +{ + $storage = new SplObjectStorage(); + $storage[[1, 2, 3]] = 'test'; + assertType("SplObjectStorage", $storage); +} + +function invalidArgument(): void +{ + $collection = new Collection(); + $collection->add('invalid'); + assertType('TemplateArgumentBounds\Collection', $collection); +} + +function partiallyValidArgument(stdClass|string $item): void +{ + $collection = new Collection(); + $collection->add($item); + assertType('TemplateArgumentBounds\Collection', $collection); +} + +function validArgumentAfterInvalidArgument(stdClass $item): void +{ + $collection = new Collection(); + $collection->add('invalid'); + $collection->add($item); + assertType('TemplateArgumentBounds\Collection', $collection); +} diff --git a/tests/PHPStan/Rules/Arrays/OffsetAccessAssignmentRuleTest.php b/tests/PHPStan/Rules/Arrays/OffsetAccessAssignmentRuleTest.php index f54f13052bc..aef7bca9100 100644 --- a/tests/PHPStan/Rules/Arrays/OffsetAccessAssignmentRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/OffsetAccessAssignmentRuleTest.php @@ -69,7 +69,7 @@ public function testOffsetAccessAssignmentToScalar(): void 68, ], [ - 'Cannot assign offset array{1, 2, 3} to SplObjectStorage.', + 'Cannot assign offset array{1, 2, 3} to SplObjectStorage.', 72, ], [ @@ -111,7 +111,7 @@ public function testOffsetAccessAssignmentToScalarWithoutMaybes(): void 68, ], [ - 'Cannot assign offset array{1, 2, 3} to SplObjectStorage.', + 'Cannot assign offset array{1, 2, 3} to SplObjectStorage.', 72, ], [ From 8da57e4c40df2ff86254ee167ab37e6539cd7cee Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 7 Sep 2026 10:11:05 +0200 Subject: [PATCH 19/28] Use template bounds for unconstrained generic call arguments --- .../Generics/TemplateArgumentObserver.php | 38 +++++++++----- .../template-argument-unconstrained-send.php | 51 +++++++++++++++++++ .../Rules/Functions/data/bug-15168.php | 30 +++++++++++ .../Rules/Methods/CallMethodsRuleTest.php | 9 ++++ 4 files changed, 114 insertions(+), 14 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/template-argument-unconstrained-send.php diff --git a/src/Analyser/Generics/TemplateArgumentObserver.php b/src/Analyser/Generics/TemplateArgumentObserver.php index 6024b60eb15..3ffb3fc330b 100644 --- a/src/Analyser/Generics/TemplateArgumentObserver.php +++ b/src/Analyser/Generics/TemplateArgumentObserver.php @@ -66,21 +66,21 @@ public function collectArgument(Type $parameterType, Type $argumentType): Templa * $actual flows into $declared: a property's writable type, a parameter * type, a declared return type, a @var type. */ - private function observeSend(TemplateArgumentConstraints $constraints, Type $declared, Type $actual): TemplateArgumentConstraints + private function observeSend(TemplateArgumentConstraints $constraints, Type $declared, Type $actual, bool $isCallArgument = false): TemplateArgumentConstraints { if ($declared instanceof TemplateType || !$this->containsMarker($actual)) { return $constraints; } if ($actual instanceof UnionType) { foreach ($actual->getTypes() as $member) { - $constraints = $this->observeSend($constraints, $declared, $member); + $constraints = $this->observeSend($constraints, $declared, $member, $isCallArgument); } return $constraints; } if ($declared instanceof UnionType) { foreach ($declared->getTypes() as $member) { - $constraints = $this->observeSend($constraints, $member, $actual); + $constraints = $this->observeSend($constraints, $member, $actual, $isCallArgument); } return $constraints; @@ -119,16 +119,14 @@ private function observeSend(TemplateArgumentConstraints $constraints, Type $dec } $declaredArgument = $declaredArguments[$i]; if (!$argument instanceof UnresolvedTemplateArgumentType) { - $constraints = $this->observeSend($constraints, $declaredArgument, $argument); + $constraints = $this->observeSend($constraints, $declaredArgument, $argument, $isCallArgument); continue; } if (self::isUninformativeSendTarget($declaredArgument)) { - if ($declaredArgument instanceof MixedType && !$declaredArgument instanceof TemplateType) { - // mixed accepts every argument, so it decides nothing - but the - // object did leave the body through it, which is more than the - // untouched `new Foo()` that resolves to never. A target that - // still carries template types is not such a signal: it is not - // a target yet. + // An unresolved call parameter, like mixed, uses the object without + // constraining it. Return/property templates are fixed by their + // declaration and must keep an empty argument compatible with them. + if (($isCallArgument && self::hasOnlyInferableTemplates($declaredArgument)) || ($declaredArgument instanceof MixedType && !$declaredArgument instanceof TemplateType)) { $constraints = $constraints->withUnconstrainingSend($argument); } @@ -144,7 +142,7 @@ private function observeSend(TemplateArgumentConstraints $constraints, Type $dec if ($initial === null) { continue; } - $constraints = $this->observeSend($constraints, $declaredArgument, $initial); + $constraints = $this->observeSend($constraints, $declaredArgument, $initial, $isCallArgument); } return $constraints; @@ -158,8 +156,8 @@ private function observeSend(TemplateArgumentConstraints $constraints, Type $dec return $constraints; } - $constraints = $this->observeSend($constraints, $declared->getIterableKeyType(), $actual->getIterableKeyType()); - $constraints = $this->observeSend($constraints, $declared->getIterableValueType(), $actual->getIterableValueType()); + $constraints = $this->observeSend($constraints, $declared->getIterableKeyType(), $actual->getIterableKeyType(), $isCallArgument); + $constraints = $this->observeSend($constraints, $declared->getIterableValueType(), $actual->getIterableValueType(), $isCallArgument); return $constraints; } @@ -171,7 +169,7 @@ private function observeSend(TemplateArgumentConstraints $constraints, Type $dec */ private function observeArgument(TemplateArgumentConstraints $constraints, Type $parameterType, Type $argumentType): TemplateArgumentConstraints { - $constraints = $this->observeSend($constraints, $parameterType, $argumentType); + $constraints = $this->observeSend($constraints, $parameterType, $argumentType, true); $constraints = $this->observeLowerBound($constraints, $parameterType, $argumentType); return $constraints; @@ -257,4 +255,16 @@ private static function isUninformativeSendTarget(Type $declaredArgument): bool || $declaredArgument->hasTemplateOrLateResolvableType(); } + private static function hasOnlyInferableTemplates(Type $type): bool + { + $references = $type->getReferencedTemplateTypes(TemplateTypeVariance::createInvariant()); + foreach ($references as $reference) { + if ($reference->getType()->isArgument()) { + return false; + } + } + + return count($references) > 0; + } + } diff --git a/tests/PHPStan/Analyser/nsrt/template-argument-unconstrained-send.php b/tests/PHPStan/Analyser/nsrt/template-argument-unconstrained-send.php new file mode 100644 index 00000000000..84642985b7c --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/template-argument-unconstrained-send.php @@ -0,0 +1,51 @@ + $collection + * @return T + */ +function read(Collection $collection) +{ +} + +/** @param Collection $collection */ +function takeInts(Collection $collection): void +{ +} + +function unconstrainedConsumer(): void +{ + $collection = new Collection(null); + assertType('mixed', read($collection)); + assertType('TemplateArgumentUnconstrainedSend\Collection', $collection); +} + +function concreteConsumer(): void +{ + $collection = new Collection(null); + assertType('int', read($collection)); + takeInts($collection); + assertType('TemplateArgumentUnconstrainedSend\Collection', $collection); +} + +function untouchedCollection(): void +{ + $collection = new Collection(null); + assertType('TemplateArgumentUnconstrainedSend\Collection<*NEVER*>', $collection); +} diff --git a/tests/PHPStan/Rules/Functions/data/bug-15168.php b/tests/PHPStan/Rules/Functions/data/bug-15168.php index 22316ac194d..c198786c515 100644 --- a/tests/PHPStan/Rules/Functions/data/bug-15168.php +++ b/tests/PHPStan/Rules/Functions/data/bug-15168.php @@ -127,3 +127,33 @@ function test(): void variadic(null, null); fromClassString(null); } + +/** @template T */ +class Sink +{ + + /** @param Coll $collection */ + public function take(Coll $collection): void + { + } + + /** + * @template U + * @param Coll $collection + * @return U + */ + public function read(Coll $collection) + { + } + +} + +/** + * @template T + * @param Sink $sink + */ +function fixedReceiverTemplate(Sink $sink): void +{ + $sink->take(new Coll(null)); + $sink->read(new Coll(null)); +} diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index 7f561aa99ff..5bba9ad9c44 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -4384,4 +4384,13 @@ public function testBug6732(): void ]); } + public function testUnconstrainedCollectionTemplateArguments(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->checkExplicitMixed = true; + $this->analyse([__DIR__ . '/../Functions/data/bug-15168.php'], []); + } + } From 2c6d58221224cdcf299c559d1e71d7a416fa2a1d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 7 Sep 2026 11:31:50 +0200 Subject: [PATCH 20/28] Preserve template defaults when generic calls add no constraints --- .../Generics/TemplateArgumentSolver.php | 4 +- .../template-argument-unconstrained-send.php | 39 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/Analyser/Generics/TemplateArgumentSolver.php b/src/Analyser/Generics/TemplateArgumentSolver.php index 5ab97358f17..d42a81bfb33 100644 --- a/src/Analyser/Generics/TemplateArgumentSolver.php +++ b/src/Analyser/Generics/TemplateArgumentSolver.php @@ -170,9 +170,9 @@ private function resolveObservation(array $observation): Type if (count($parts) === 0) { if ($observation['unconstrainingSend']) { // sent to a target that accepts anything: the object is in use, so - // the template's bound is what is known about the argument - never + // the template's default or bound describes the argument - never // would make every later read of it an error - return $observation['marker']->getTemplate()->getBound(); + return $template->getDefault() ?? $template->getBound(); } if ($initial instanceof NeverType) { return $initial; diff --git a/tests/PHPStan/Analyser/nsrt/template-argument-unconstrained-send.php b/tests/PHPStan/Analyser/nsrt/template-argument-unconstrained-send.php index 84642985b7c..1e79d1fff73 100644 --- a/tests/PHPStan/Analyser/nsrt/template-argument-unconstrained-send.php +++ b/tests/PHPStan/Analyser/nsrt/template-argument-unconstrained-send.php @@ -49,3 +49,42 @@ function untouchedCollection(): void $collection = new Collection(null); assertType('TemplateArgumentUnconstrainedSend\Collection<*NEVER*>', $collection); } + +/** @template ID of string|array = string */ +class Criteria +{ + + /** @param array|null $ids */ + public function __construct(?array $ids = null) + { + } + +} + +abstract class Repository +{ + + /** + * @template ID of string|array = string + * @param Criteria $criteria + * @return list + */ + abstract public function searchIds(Criteria $criteria): array; + +} + +function defaultTemplateArgument(Repository $repository): void +{ + $criteria = new Criteria(); + $ids = $repository->searchIds($criteria); + assertType('TemplateArgumentUnconstrainedSend\Criteria', $criteria); + assertType('list', $ids); +} + +function inferredTemplateArgumentOverridesDefault(Repository $repository): void +{ + $criteria = new Criteria([['id' => 'foo']]); + $ids = $repository->searchIds($criteria); + assertType("TemplateArgumentUnconstrainedSend\Criteria", $criteria); + assertType("list", $ids); +} From 952645bbb33a134a827a4b9d1982cbf13a61f629 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 7 Sep 2026 11:34:59 +0200 Subject: [PATCH 21/28] Use generic parameter defaults to constrain empty nested collections --- .../Generics/TemplateArgumentObserver.php | 8 +++ .../Rules/Classes/InstantiationRuleTest.php | 10 +++ .../template-argument-nested-collection.php | 63 +++++++++++++++++++ 3 files changed, 81 insertions(+) create mode 100644 tests/PHPStan/Rules/Classes/data/template-argument-nested-collection.php diff --git a/src/Analyser/Generics/TemplateArgumentObserver.php b/src/Analyser/Generics/TemplateArgumentObserver.php index 3ffb3fc330b..689f6da08bb 100644 --- a/src/Analyser/Generics/TemplateArgumentObserver.php +++ b/src/Analyser/Generics/TemplateArgumentObserver.php @@ -4,6 +4,7 @@ use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\Generic\TemplateType; +use PHPStan\Type\Generic\TemplateTypeHelper; use PHPStan\Type\Generic\TemplateTypeVariance; use PHPStan\Type\Generic\UnresolvedTemplateArgumentType; use PHPStan\Type\MixedType; @@ -122,6 +123,13 @@ private function observeSend(TemplateArgumentConstraints $constraints, Type $dec $constraints = $this->observeSend($constraints, $declaredArgument, $argument, $isCallArgument); continue; } + if ( + $isCallArgument + && ($argument->getInitialType() === null || $argument->getInitialType() instanceof NeverType) + && self::hasOnlyInferableTemplates($declaredArgument) + ) { + $declaredArgument = TemplateTypeHelper::resolveToDefaults($declaredArgument); + } if (self::isUninformativeSendTarget($declaredArgument)) { // An unresolved call parameter, like mixed, uses the object without // constraining it. Return/property templates are fixed by their diff --git a/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php b/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php index 2d1734cb0ac..c7dc706e5d4 100644 --- a/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php +++ b/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php @@ -76,6 +76,16 @@ public function testTemplateArgumentArrow(): void $this->analyse([__DIR__ . '/data/template-argument-arrow.php'], []); } + public function testTemplateArgumentNestedCollection(): void + { + $this->analyse([__DIR__ . '/data/template-argument-nested-collection.php'], [ + [ + 'Parameter #1 $events of class TemplateArgumentNestedCollection\ContainerEvent constructor expects TemplateArgumentNestedCollection\EventCollection|string = string>>, TemplateArgumentNestedCollection\EventCollection given.', + 62, + ], + ]); + } + public function testInstantiation(): void { $this->analyse( diff --git a/tests/PHPStan/Rules/Classes/data/template-argument-nested-collection.php b/tests/PHPStan/Rules/Classes/data/template-argument-nested-collection.php new file mode 100644 index 00000000000..f50072b3d64 --- /dev/null +++ b/tests/PHPStan/Rules/Classes/data/template-argument-nested-collection.php @@ -0,0 +1,63 @@ + $items */ + public function __construct(iterable $items = []) + { + } + +} + +/** + * @template T of Event = Event + * @extends Collection + */ +class EventCollection extends Collection +{ +} + +/** @template ID of string|array = string */ +class WrittenEvent extends Event +{ + + /** @param ID $id */ + public function __construct($id) + { + } + +} + +/** @template ID of string|array = string */ +class ContainerEvent +{ + + /** @param EventCollection> $events */ + public function __construct(EventCollection $events) + { + } + +} + +function emptyCollection(): void +{ + new ContainerEvent(new EventCollection()); +} + +function populatedCollection(): void +{ + new ContainerEvent(new EventCollection([new WrittenEvent('foo')])); +} + +function incompatibleCollection(): void +{ + new ContainerEvent(new EventCollection([new Event()])); +} From 194bd0e22ed61c8c4bc61553a48c6140ade867f4 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 7 Sep 2026 11:43:23 +0200 Subject: [PATCH 22/28] Use template bounds for objects passed to impure mixed parameters --- .../Generics/TemplateArgumentObserver.php | 12 +++++++++- src/Analyser/NodeScopeResolver.php | 1 + .../template-argument-unconstrained-send.php | 23 +++++++++++++++++++ .../CallToFunctionParametersRuleTest.php | 5 ++++ .../template-argument-reduce-iterator.php | 18 +++++++++++++++ 5 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Rules/Functions/data/template-argument-reduce-iterator.php diff --git a/src/Analyser/Generics/TemplateArgumentObserver.php b/src/Analyser/Generics/TemplateArgumentObserver.php index 689f6da08bb..17ff4267688 100644 --- a/src/Analyser/Generics/TemplateArgumentObserver.php +++ b/src/Analyser/Generics/TemplateArgumentObserver.php @@ -58,8 +58,12 @@ public function collectSend(Type $declared, Type $actual): TemplateArgumentConst return $this->observeSend(TemplateArgumentConstraints::createEmpty(), $declared, $actual); } - public function collectArgument(Type $parameterType, Type $argumentType): TemplateArgumentConstraints + public function collectArgument(Type $parameterType, Type $argumentType, bool $isPure = false): TemplateArgumentConstraints { + // A pure consumer accepting anything cannot initialize an empty object. + if ($isPure && $parameterType instanceof MixedType && !$parameterType instanceof TemplateType) { + return TemplateArgumentConstraints::createEmpty(); + } return $this->observeArgument(TemplateArgumentConstraints::createEmpty(), $parameterType, $argumentType); } @@ -72,6 +76,12 @@ private function observeSend(TemplateArgumentConstraints $constraints, Type $dec if ($declared instanceof TemplateType || !$this->containsMarker($actual)) { return $constraints; } + if ($isCallArgument && $declared instanceof MixedType) { + foreach ($this->collectSites($actual)->getFacts() as [$marker]) { + $constraints = $constraints->withUnconstrainingSend($marker); + } + return $constraints; + } if ($actual instanceof UnionType) { foreach ($actual->getTypes() as $member) { $constraints = $this->observeSend($constraints, $declared, $member, $isCallArgument); diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index eb17f971f87..d76c34109bb 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -3131,6 +3131,7 @@ public function processArgs( $scope = $scope->addTemplateArgumentConstraints($this->templateArgumentObserver->collectArgument( $this->findOriginalParameterType($argMetadataAcceptor, $parameter) ?? $parameter->getType(), $gatheredArgTypeByIndex[$i], + ($calleeReflection instanceof FunctionReflection || $calleeReflection instanceof ExtendedMethodReflection) && $calleeReflection->isPure()->yes(), )); } } diff --git a/tests/PHPStan/Analyser/nsrt/template-argument-unconstrained-send.php b/tests/PHPStan/Analyser/nsrt/template-argument-unconstrained-send.php index 1e79d1fff73..a9a4615dba9 100644 --- a/tests/PHPStan/Analyser/nsrt/template-argument-unconstrained-send.php +++ b/tests/PHPStan/Analyser/nsrt/template-argument-unconstrained-send.php @@ -50,6 +50,29 @@ function untouchedCollection(): void assertType('TemplateArgumentUnconstrainedSend\Collection<*NEVER*>', $collection); } +function consumeMixed($value): void +{ +} + +/** @phpstan-pure */ +function inspectMixed($value): void +{ +} + +function mixedConsumer(): void +{ + $collection = new Collection(null); + consumeMixed($collection); + assertType('TemplateArgumentUnconstrainedSend\Collection', $collection); +} + +function pureMixedConsumer(): void +{ + $collection = new Collection(null); + inspectMixed($collection); + assertType('TemplateArgumentUnconstrainedSend\Collection<*NEVER*>', $collection); +} + /** @template ID of string|array = string */ class Criteria { diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index 75e5828e59a..80c6d38bbc7 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -52,6 +52,11 @@ protected function getRule(): Rule ); } + public function testTemplateArgumentReduceIterator(): void + { + $this->analyse([__DIR__ . '/data/template-argument-reduce-iterator.php'], []); + } + public function testCallToFunctionWithoutParameters(): void { require_once __DIR__ . '/data/existing-function-definition.php'; diff --git a/tests/PHPStan/Rules/Functions/data/template-argument-reduce-iterator.php b/tests/PHPStan/Rules/Functions/data/template-argument-reduce-iterator.php new file mode 100644 index 00000000000..f3109ea2ff0 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/template-argument-reduce-iterator.php @@ -0,0 +1,18 @@ +> $iterators */ +function iteratorClosure(array $iterators): \Closure +{ + return function () use ($iterators) { + return array_reduce( + $iterators, + static function (\AppendIterator $global, \Iterator $iterator) { + $global->append($iterator); + return $global; + }, + new \AppendIterator() + ); + }; +} From 5e5139b113b9f243c8134cd8fa5efd255c8477e5 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 7 Sep 2026 11:50:20 +0200 Subject: [PATCH 23/28] Preserve unknown ArrayObject values when constructed from an object --- ...ayObjectConstructorReturnTypeExtension.php | 47 +++++++++++++++++++ .../nsrt/array-object-object-input.php | 43 +++++++++++++++++ .../Rules/Functions/ReturnTypeRuleTest.php | 7 +++ 3 files changed, 97 insertions(+) create mode 100644 src/Type/Php/ArrayObjectConstructorReturnTypeExtension.php create mode 100644 tests/PHPStan/Analyser/nsrt/array-object-object-input.php diff --git a/src/Type/Php/ArrayObjectConstructorReturnTypeExtension.php b/src/Type/Php/ArrayObjectConstructorReturnTypeExtension.php new file mode 100644 index 00000000000..a8a375f4311 --- /dev/null +++ b/src/Type/Php/ArrayObjectConstructorReturnTypeExtension.php @@ -0,0 +1,47 @@ +getName() === '__construct'; + } + + public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type + { + if (!$methodCall->class instanceof Name || strtolower($scope->resolveName($methodCall->class)) !== 'arrayobject') { + return null; + } + $args = $methodCall->getArgs(); + if (!isset($args[0]) || !$scope->getType($args[0]->value)->isObject()->yes()) { + return null; + } + + // The object branch of array|object does not infer the + // templates, but its properties can still populate the ArrayObject. + $mixed = new MixedType(); + return new GenericObjectType(ArrayObject::class, [$mixed->toArrayKey(), $mixed]); + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/array-object-object-input.php b/tests/PHPStan/Analyser/nsrt/array-object-object-input.php new file mode 100644 index 00000000000..4fc1f75ef93 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/array-object-object-input.php @@ -0,0 +1,43 @@ + 'foo']); + assertType("'foo'|null", $array['value']); +} + +/** @extends ArrayObject */ +class Child extends ArrayObject +{ +} + +function inheritedConstructor(__PHP_Incomplete_Class $value): void +{ + $array = new Child($value); + assertType('ArrayObjectObjectInput\Child', $array); + assertType('mixed', $array['__PHP_Incomplete_Class_Name']); +} + +function className(__PHP_Incomplete_Class $value): string +{ + $array = new ArrayObject($value); + return $array['__PHP_Incomplete_Class_Name']; +} diff --git a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php index 511324142f6..fc054a0f907 100644 --- a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php @@ -34,6 +34,13 @@ protected function getRule(): Rule )); } + public function testArrayObjectObjectInput(): void + { + $this->checkNullables = true; + $this->checkExplicitMixed = false; + $this->analyse([__DIR__ . '/../../Analyser/nsrt/array-object-object-input.php'], []); + } + public function testReturnTypeRule(): void { require_once __DIR__ . '/data/returnTypes.php'; From 87a4b796831ed0ffd85e5848bcbe43eaa01af12b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 7 Sep 2026 11:57:59 +0200 Subject: [PATCH 24/28] Preserve inferred arguments when return types omit generic arguments --- .../Generics/TemplateArgumentObserver.php | 3 +- .../nsrt/template-argument-raw-return.php | 55 +++++++++++++++++++ .../WrongVariableNameInVarTagRuleTest.php | 6 ++ 3 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Analyser/nsrt/template-argument-raw-return.php diff --git a/src/Analyser/Generics/TemplateArgumentObserver.php b/src/Analyser/Generics/TemplateArgumentObserver.php index 17ff4267688..ef99faa1659 100644 --- a/src/Analyser/Generics/TemplateArgumentObserver.php +++ b/src/Analyser/Generics/TemplateArgumentObserver.php @@ -121,7 +121,8 @@ private function observeSend(TemplateArgumentConstraints $constraints, Type $dec } $templates = $ancestor->typeMapToList($ancestor->getTemplateTypeMap()); - $declaredArguments = $declaredReflection->typeMapToList($declaredReflection->getActiveTemplateTypeMap()); + // Omitted arguments are not explicit constraints to widen to the bounds. + $declaredArguments = $declaredReflection->typeMapToList($declaredReflection->getPossiblyIncompleteActiveTemplateTypeMap()); $declaredVariances = $declaredReflection->getCallSiteVarianceMap(); foreach ($ancestor->typeMapToList($ancestor->getActiveTemplateTypeMap()) as $i => $argument) { $template = $templates[$i] ?? null; diff --git a/tests/PHPStan/Analyser/nsrt/template-argument-raw-return.php b/tests/PHPStan/Analyser/nsrt/template-argument-raw-return.php new file mode 100644 index 00000000000..4e3d4f99a3a --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/template-argument-raw-return.php @@ -0,0 +1,55 @@ + $repository */ + public function __construct(Repository $repository) + { + } + +} + +/** + * @template R + * @param \Closure(): R $callback + * @return R + */ +function run(\Closure $callback) +{ + return $callback(); +} + +/** @param Repository> $repository */ +function test(Repository $repository): void +{ + $iterator = run(static fn (): RepositoryIterator => new RepositoryIterator($repository)); + assertType('TemplateArgumentRawReturn\RepositoryIterator>', $iterator); + + $closureIterator = run(static function () use ($repository): RepositoryIterator { + return new RepositoryIterator($repository); + }); + assertType('TemplateArgumentRawReturn\RepositoryIterator>', $closureIterator); + + /** @var RepositoryIterator> $documentedIterator */ + $documentedIterator = run(static fn (): RepositoryIterator => new RepositoryIterator($repository)); +} diff --git a/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php b/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php index 7b7dac3eb84..5f48a7ac27f 100644 --- a/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php @@ -640,4 +640,10 @@ public function testDestructuringChecksAgainstNativeOffsetType(): void ]); } + public function testTemplateArgumentRawReturn(): void + { + $this->checkTypeAgainstPhpDocType = true; + $this->analyse([__DIR__ . '/../../Analyser/nsrt/template-argument-raw-return.php'], []); + } + } From 24164110cefab401e35dfcdd6ee2648b071599de Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 7 Sep 2026 12:00:14 +0200 Subject: [PATCH 25/28] Use a complete PHP version requirement in Bug14396Test --- tests/PHPStan/Rules/Exceptions/Bug14396Test.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PHPStan/Rules/Exceptions/Bug14396Test.php b/tests/PHPStan/Rules/Exceptions/Bug14396Test.php index bb0e9c982ed..91955a26b87 100644 --- a/tests/PHPStan/Rules/Exceptions/Bug14396Test.php +++ b/tests/PHPStan/Rules/Exceptions/Bug14396Test.php @@ -30,7 +30,7 @@ protected function shouldTreatPhpDocTypesAsCertain(): bool return false; } - #[RequiresPhp('>= 8.1')] + #[RequiresPhp('>= 8.1.0')] public function testRule(): void { $this->analyse([__DIR__ . '/data/bug-14396.php'], []); From b379e3d434687e0ae9d652fcfc7beeb7a7e6addb Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 7 Sep 2026 12:09:35 +0200 Subject: [PATCH 26/28] Update levels expectations for inferred SplObjectStorage values --- tests/PHPStan/Levels/data/arrayAccess-10.json | 2 +- tests/PHPStan/Levels/data/arrayAccess-3.json | 22 +------------------ tests/PHPStan/Levels/data/arrayAccess-7.json | 2 +- 3 files changed, 3 insertions(+), 23 deletions(-) diff --git a/tests/PHPStan/Levels/data/arrayAccess-10.json b/tests/PHPStan/Levels/data/arrayAccess-10.json index 30ba124708b..f25fcbfbe8f 100644 --- a/tests/PHPStan/Levels/data/arrayAccess-10.json +++ b/tests/PHPStan/Levels/data/arrayAccess-10.json @@ -1,6 +1,6 @@ [ { - "message": "Cannot assign offset mixed to SplObjectStorage.", + "message": "Cannot assign offset mixed to SplObjectStorage.", "line": 43, "ignorable": true } diff --git a/tests/PHPStan/Levels/data/arrayAccess-3.json b/tests/PHPStan/Levels/data/arrayAccess-3.json index 4916228404e..63d3d8ddfd8 100644 --- a/tests/PHPStan/Levels/data/arrayAccess-3.json +++ b/tests/PHPStan/Levels/data/arrayAccess-3.json @@ -1,27 +1,7 @@ [ { - "message": "SplObjectStorage does not accept int.", - "line": 16, - "ignorable": true - }, - { - "message": "SplObjectStorage does not accept int.", - "line": 27, - "ignorable": true - }, - { - "message": "Cannot assign offset int to SplObjectStorage.", + "message": "Cannot assign offset int to SplObjectStorage.", "line": 35, "ignorable": true - }, - { - "message": "SplObjectStorage does not accept int.", - "line": 35, - "ignorable": true - }, - { - "message": "SplObjectStorage does not accept int.", - "line": 43, - "ignorable": true } ] \ No newline at end of file diff --git a/tests/PHPStan/Levels/data/arrayAccess-7.json b/tests/PHPStan/Levels/data/arrayAccess-7.json index 58f0c685788..7222344a563 100644 --- a/tests/PHPStan/Levels/data/arrayAccess-7.json +++ b/tests/PHPStan/Levels/data/arrayAccess-7.json @@ -1,6 +1,6 @@ [ { - "message": "Cannot assign offset int|object to SplObjectStorage.", + "message": "Cannot assign offset int|object to SplObjectStorage.", "line": 27, "ignorable": true } From a82b7e9e0b12b4923e8bafaf500959aafb009b1b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 7 Sep 2026 13:04:20 +0200 Subject: [PATCH 27/28] Preserve bounds for unconstrained template arguments --- src/Analyser/ExprHandler/NewHandler.php | 2 +- .../Generics/TemplateArgumentFrame.php | 11 +- .../Generics/TemplateArgumentResolverTest.php | 12 +- .../Analyser/NodeScopeResolverTest.php | 1 + tests/PHPStan/Analyser/nsrt/bug-6732.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-8441.php | 4 +- .../Analyser/nsrt/generics-empty-array.php | 4 +- tests/PHPStan/Analyser/nsrt/generics.php | 4 +- .../template-argument-unconstrained-send.php | 4 +- .../Rules/Methods/CallMethodsRuleTest.php | 15 ++- .../Rules/Methods/ReturnTypeRuleTest.php | 7 +- .../data/unconstrained-query-result.php | 123 ++++++++++++++++++ .../WrongVariableNameInVarTagRuleTest.php | 7 + 13 files changed, 174 insertions(+), 22 deletions(-) create mode 100644 tests/PHPStan/Rules/Methods/data/unconstrained-query-result.php diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index f10062d3ae4..152cfb1744a 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -725,7 +725,7 @@ classReflection: $classReflection->withTypes($types)->asFinal(), /** * The class's template arguments when the constructor says nothing about * them: unresolved markers during a body's observation pass, the frame's - * resolutions (never, when nothing constrained them) during its second + * resolutions (the defaults or bounds when unconstrained) during its second * pass, the bounds outside any frame. * * @return list diff --git a/src/Analyser/Generics/TemplateArgumentFrame.php b/src/Analyser/Generics/TemplateArgumentFrame.php index 0e05d8e39ed..04856d934cf 100644 --- a/src/Analyser/Generics/TemplateArgumentFrame.php +++ b/src/Analyser/Generics/TemplateArgumentFrame.php @@ -7,8 +7,6 @@ use PHPStan\Reflection\ParametersAcceptor; use PHPStan\Reflection\ResolvedFunctionVariant; use PHPStan\Type\Generic\TemplateType; -use PHPStan\Type\MixedType; -use PHPStan\Type\NeverType; use PHPStan\Type\Type; use PHPStan\Type\TypeTraverser; use function array_keys; @@ -115,9 +113,9 @@ public function resolveOrUnconstrained(Expr $site, TemplateType $template): Type } /** - * Nothing was inferred, sent or passed in: the template's default, else - * its bound when it says something (`T of Foo`, `U of T` - resolved - * against the sibling arguments), else never - the object holds nothing. + * Nothing was inferred, sent or passed in: use the template's default or + * bound, resolving sibling arguments in dependent bounds (`U of T`). + * An unknown argument does not imply that the object holds nothing. * * @param callable(Expr, string): ?Type $resolve */ @@ -129,9 +127,6 @@ public static function resolveUnconstrained(Expr $site, TemplateType $template, } $bound = $template->getBound(); - if ($bound instanceof MixedType && !$bound instanceof TemplateType) { - return new NeverType(); - } if (!$bound->hasTemplateOrLateResolvableType()) { return $bound; } diff --git a/tests/PHPStan/Analyser/Generics/TemplateArgumentResolverTest.php b/tests/PHPStan/Analyser/Generics/TemplateArgumentResolverTest.php index ee3db473365..02babade308 100644 --- a/tests/PHPStan/Analyser/Generics/TemplateArgumentResolverTest.php +++ b/tests/PHPStan/Analyser/Generics/TemplateArgumentResolverTest.php @@ -21,6 +21,7 @@ use PHPStan\Type\Generic\UnresolvedTemplateArgumentType; use PHPStan\Type\IntegerType; use PHPStan\Type\MixedType; +use PHPStan\Type\NeverType; use PHPStan\Type\NullType; use PHPStan\Type\StringType; use PHPStan\Type\Test\A; @@ -87,11 +88,11 @@ public function testNoAcceptingSendKeepsTheInitialType(): void $this->assertSame('1', self::describe($frame->resolve($site, 'T'))); } - public function testNothingInferredResolvesToNeverOrToTheSend(): void + public function testNothingInferredResolvesToTheBoundOrToTheSend(): void { [$constraints, $site] = self::constraintsWithA(null); $frame = (new TemplateArgumentResolver())->resolve($constraints, null, []); - $this->assertSame('*NEVER*', self::describe($frame->resolve($site, 'T'))); + $this->assertSame('mixed', self::describe($frame->resolve($site, 'T'))); [$constraints, $site, $ofMarker] = self::constraintsWithA(null); $constraints = $constraints->merge((new TemplateArgumentObserver())->collectSend(new GenericObjectType(A\A::class, [new StringType()]), $ofMarker)); @@ -99,6 +100,13 @@ public function testNothingInferredResolvesToNeverOrToTheSend(): void $this->assertSame('string', self::describe($frame->resolve($site, 'T')), 'nothing inferred is accepted by every send'); } + public function testEmptyInputRemainsNever(): void + { + [$constraints, $site] = self::constraintsWithA(new NeverType()); + $frame = (new TemplateArgumentResolver())->resolve($constraints, null, []); + $this->assertSame('*NEVER*', self::describe($frame->resolve($site, 'T'))); + } + public function testMixedAndTemplateTargetsAreNotSends(): void { [$constraints, $site, $ofMarker] = self::constraintsWithA(new ConstantIntegerType(1)); diff --git a/tests/PHPStan/Analyser/NodeScopeResolverTest.php b/tests/PHPStan/Analyser/NodeScopeResolverTest.php index 24f55aad643..bf1496d77dd 100644 --- a/tests/PHPStan/Analyser/NodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/NodeScopeResolverTest.php @@ -278,6 +278,7 @@ private static function findTestFiles(): iterable } yield __DIR__ . '/../Rules/Methods/data/bug-14893.php'; + yield __DIR__ . '/../Rules/Methods/data/unconstrained-query-result.php'; yield __DIR__ . '/../Rules/Variables/data/bug-13921.php'; yield __DIR__ . '/../Rules/Arrays/data/bug-14234.php'; yield __DIR__ . '/../Rules/Arrays/data/bug-11679.php'; diff --git a/tests/PHPStan/Analyser/nsrt/bug-6732.php b/tests/PHPStan/Analyser/nsrt/bug-6732.php index 21a37f6c751..d2657dd55ef 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-6732.php +++ b/tests/PHPStan/Analyser/nsrt/bug-6732.php @@ -219,7 +219,7 @@ function (): void { function (): void { $b = new Bag(); - assertType('Bug6732\Bag<*NEVER*>', $b); + assertType('Bug6732\Bag', $b); $c = new Collection(); assertType('Bug6732\Collection<*NEVER*>', $c); $e = new Collection([]); diff --git a/tests/PHPStan/Analyser/nsrt/bug-8441.php b/tests/PHPStan/Analyser/nsrt/bug-8441.php index 24da82564d7..7d04216b21e 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-8441.php +++ b/tests/PHPStan/Analyser/nsrt/bug-8441.php @@ -143,8 +143,8 @@ public function collection($x = null): Collection } function (?int $nullOrInt, int $int, Service $service): void { - assertType('Bug8441\Collection<*NEVER*>', new Collection()); - assertType('Bug8441\Collection<*NEVER*>', new Collection(null)); + assertType('Bug8441\Collection', new Collection()); + assertType('Bug8441\Collection', new Collection(null)); assertType('Bug8441\Collection', new Collection($nullOrInt)); assertType('Bug8441\Collection', new Collection($int)); assertType('Bug8441\CollectionWithNonNullableParam', new CollectionWithNonNullableParam()); diff --git a/tests/PHPStan/Analyser/nsrt/generics-empty-array.php b/tests/PHPStan/Analyser/nsrt/generics-empty-array.php index e8da03c077c..b238f3fdbf6 100644 --- a/tests/PHPStan/Analyser/nsrt/generics-empty-array.php +++ b/tests/PHPStan/Analyser/nsrt/generics-empty-array.php @@ -73,8 +73,8 @@ class Baz public function doFoo() { - assertType('GenericsEmptyArray\\ArrayCollection2<(int|string), *NEVER*>', new ArrayCollection2()); - assertType('GenericsEmptyArray\\ArrayCollection2<(int|string), *NEVER*>', new ArrayCollection2([])); + assertType('GenericsEmptyArray\\ArrayCollection2<(int|string), mixed>', new ArrayCollection2()); + assertType('GenericsEmptyArray\\ArrayCollection2<(int|string), mixed>', new ArrayCollection2([])); } } diff --git a/tests/PHPStan/Analyser/nsrt/generics.php b/tests/PHPStan/Analyser/nsrt/generics.php index 5ab887874f3..6e150f33349 100644 --- a/tests/PHPStan/Analyser/nsrt/generics.php +++ b/tests/PHPStan/Analyser/nsrt/generics.php @@ -887,9 +887,9 @@ function cache1($t): void { } function newHandling(): void { - assertType('PHPStan\Generics\FunctionsAssertType\C<*NEVER*>', new C()); + assertType('PHPStan\Generics\FunctionsAssertType\C', new C()); assertType('PHPStan\Generics\FunctionsAssertType\A', new A(new \stdClass())); - assertType('PHPStan\Generics\FunctionsAssertType\A<*NEVER*>', new A()); + assertType('PHPStan\Generics\FunctionsAssertType\A', new A()); } /** diff --git a/tests/PHPStan/Analyser/nsrt/template-argument-unconstrained-send.php b/tests/PHPStan/Analyser/nsrt/template-argument-unconstrained-send.php index a9a4615dba9..1f0792212c3 100644 --- a/tests/PHPStan/Analyser/nsrt/template-argument-unconstrained-send.php +++ b/tests/PHPStan/Analyser/nsrt/template-argument-unconstrained-send.php @@ -47,7 +47,7 @@ function concreteConsumer(): void function untouchedCollection(): void { $collection = new Collection(null); - assertType('TemplateArgumentUnconstrainedSend\Collection<*NEVER*>', $collection); + assertType('TemplateArgumentUnconstrainedSend\Collection', $collection); } function consumeMixed($value): void @@ -70,7 +70,7 @@ function pureMixedConsumer(): void { $collection = new Collection(null); inspectMixed($collection); - assertType('TemplateArgumentUnconstrainedSend\Collection<*NEVER*>', $collection); + assertType('TemplateArgumentUnconstrainedSend\Collection', $collection); } /** @template ID of string|array = string */ diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index 5bba9ad9c44..6f702181b82 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -4390,7 +4390,20 @@ public function testUnconstrainedCollectionTemplateArguments(): void $this->checkNullables = true; $this->checkUnionTypes = true; $this->checkExplicitMixed = true; - $this->analyse([__DIR__ . '/../Functions/data/bug-15168.php'], []); + $this->analyse([__DIR__ . '/../Functions/data/bug-15168.php'], [ + [ + 'Parameter #1 $collection of method Bug15168Functions\Sink::take() expects Bug15168Functions\Coll, Bug15168Functions\Coll given.', + 157, + ], + ]); + } + + public function testUnconstrainedQueryResult(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/unconstrained-query-result.php'], []); } } diff --git a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php index 57094ba6f31..195bce3914c 100644 --- a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php @@ -674,7 +674,12 @@ public function testBug5065(): void public function testBug5065ExplicitMixed(): void { $this->checkExplicitMixed = true; - $this->analyse([__DIR__ . '/data/bug-5065.php'], []); + $this->analyse([__DIR__ . '/data/bug-5065.php'], [ + [ + 'Method Bug5065\Collection::emptyWorkaround2() should return Bug5065\Collection but returns Bug5065\Collection<(int|string), mixed>.', + 60, + ], + ]); } public function testBug3400(): void diff --git a/tests/PHPStan/Rules/Methods/data/unconstrained-query-result.php b/tests/PHPStan/Rules/Methods/data/unconstrained-query-result.php new file mode 100644 index 00000000000..9ac1d3b5a4e --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/unconstrained-query-result.php @@ -0,0 +1,123 @@ + + */ +class QueryResultSet implements IteratorAggregate +{ + + public function __construct(Query $query) + { + } + + /** @return Traversable */ + public function getIterator(): Traversable + { + throw new LogicException(); + } + + /** @return iterable */ + public function toIterable(): iterable + { + throw new LogicException(); + } + + /** @return array */ + public function toArray(): array + { + throw new LogicException(); + } + +} + +/** + * @template-covariant T + * @implements Iterator + */ +class BatchIterator implements Iterator +{ + + public function __construct(QueryBuilder $queryBuilder, string $uniqueIdColumn, int $batchSize = 100) + { + } + + /** @return T */ + public function current() + { + throw new LogicException(); + } + + public function key(): int + { + throw new LogicException(); + } + + public function next(): void + { + } + + public function rewind(): void + { + } + + public function valid(): bool + { + throw new LogicException(); + } + +} + +function queryResultSet(Query $query): void +{ + $results = new QueryResultSet($query); + assertType('UnconstrainedQueryResult\QueryResultSet', $results); + assertNativeType('UnconstrainedQueryResult\QueryResultSet', $results); + assertType('array', $results->toArray()); + assertType('iterable', $results->toIterable()); + + foreach ($results->toIterable() as $row) { + assertType('mixed', $row); + assertNativeType('mixed', $row); + } + + /** @var stdClass $row */ + foreach ($results->toIterable() as $row) { + assertType('stdClass', $row); + } +} + +function batchIterator(QueryBuilder $queryBuilder): void +{ + $rows = new BatchIterator($queryBuilder, 'f.id', 5000); + assertType('UnconstrainedQueryResult\BatchIterator', $rows); + assertNativeType('UnconstrainedQueryResult\BatchIterator', $rows); + + foreach ($rows as $row) { + assertType('mixed', $row); + assertNativeType('mixed', $row); + } + + /** @var stdClass $row */ + foreach ($rows as $row) { + assertType('stdClass', $row); + } +} diff --git a/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php b/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php index 5f48a7ac27f..ed6e20e2c2d 100644 --- a/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php @@ -646,4 +646,11 @@ public function testTemplateArgumentRawReturn(): void $this->analyse([__DIR__ . '/../../Analyser/nsrt/template-argument-raw-return.php'], []); } + public function testUnconstrainedQueryResult(): void + { + $this->checkTypeAgainstPhpDocType = true; + $this->strictWideningCheck = true; + $this->analyse([__DIR__ . '/../Methods/data/unconstrained-query-result.php'], []); + } + } From 66ec5e6699558b38eba7555b9da68a3a4c10c532 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 7 Sep 2026 16:08:50 +0200 Subject: [PATCH 28/28] Solve linked invariant template arguments together --- .../Generics/TemplateArgumentObserver.php | 82 ++++++ .../Generics/TemplateArgumentSolver.php | 92 +++++- src/Analyser/NodeScopeResolver.php | 9 + .../Generics/TemplateArgumentFlowTest.php | 6 + .../Generics/data/joint-inference-errors.php | 86 ++++++ .../Generics/data/joint-inference.php | 274 ++++++++++++++++++ .../Rules/Classes/InstantiationRuleTest.php | 19 ++ .../CallToFunctionParametersRuleTest.php | 32 ++ .../Functions/data/joint-inference-named.php | 18 ++ .../Rules/Methods/CallMethodsRuleTest.php | 34 +++ 10 files changed, 651 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Analyser/Generics/data/joint-inference-errors.php create mode 100644 tests/PHPStan/Analyser/Generics/data/joint-inference.php create mode 100644 tests/PHPStan/Rules/Functions/data/joint-inference-named.php diff --git a/src/Analyser/Generics/TemplateArgumentObserver.php b/src/Analyser/Generics/TemplateArgumentObserver.php index ef99faa1659..1a0469118e5 100644 --- a/src/Analyser/Generics/TemplateArgumentObserver.php +++ b/src/Analyser/Generics/TemplateArgumentObserver.php @@ -2,9 +2,13 @@ namespace PHPStan\Analyser\Generics; +use PhpParser\Node\Expr; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Reflection\ParametersAcceptor; +use PHPStan\Reflection\ResolvedFunctionVariant; use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\Generic\TemplateTypeHelper; +use PHPStan\Type\Generic\TemplateTypeMap; use PHPStan\Type\Generic\TemplateTypeVariance; use PHPStan\Type\Generic\UnresolvedTemplateArgumentType; use PHPStan\Type\MixedType; @@ -12,7 +16,9 @@ use PHPStan\Type\Type; use PHPStan\Type\TypeTraverser; use PHPStan\Type\UnionType; +use function array_merge; use function count; +use function is_string; /** * Matches declared and actual types to collect constraints on unresolved @@ -67,6 +73,64 @@ public function collectArgument(Type $parameterType, Type $argumentType, bool $i return $this->observeArgument(TemplateArgumentConstraints::createEmpty(), $parameterType, $argumentType); } + /** + * Keep a call's inferable parameters shared across all of its arguments. + * Invariant uses relate fresh instances instead of fixing each one from + * the arguments seen so far. The call's return type uses the same site. + * + * @param array $argumentTypes + */ + public function collectCall(Expr $site, ParametersAcceptor $acceptor, array $argumentTypes, ?TemplateTypeMap $classTemplates = null): TemplateArgumentConstraints + { + $constraints = TemplateArgumentConstraints::createEmpty(); + if ($acceptor instanceof ResolvedFunctionVariant) { + $acceptor = $acceptor->getOriginalParametersAcceptor(); + } + $templates = new TemplateTypeMap(array_merge($classTemplates !== null ? $classTemplates->getTypes() : [], $acceptor->getTemplateTypeMap()->getTypes())); + if ($templates->isEmpty()) { + return $constraints; + } + $hasMarkers = false; + foreach ($argumentTypes as $argumentType) { + if (!$this->containsMarker($argumentType)) { + continue; + } + $hasMarkers = true; + break; + } + if (!$hasMarkers) { + return $constraints; + } + + $parameters = $acceptor->getParameters(); + $parametersByName = []; + foreach ($parameters as $parameter) { + $parametersByName[$parameter->getName()] = $parameter; + } + foreach ($argumentTypes as $i => $argumentType) { + $parameter = is_string($i) ? ($parametersByName[$i] ?? null) : ($parameters[$i] ?? null); + $parameter ??= $acceptor->isVariadic() && count($parameters) > 0 ? $parameters[count($parameters) - 1] : null; + if ($parameter === null) { + continue; + } + $parameterType = TypeTraverser::map($parameter->getType(), static function (Type $type, callable $traverse) use ($site, $templates, &$constraints): Type { + if (!$type instanceof TemplateType || $type->isArgument()) { + return $traverse($type); + } + $template = $templates->getType($type->getName()); + if (!$template instanceof TemplateType || !$template->getScope()->equals($type->getScope())) { + return $type; + } + $marker = new UnresolvedTemplateArgumentType($site, $type, null); + $constraints = $constraints->withSite($marker); + return $marker; + }); + $constraints = $this->observeArgument($constraints, $parameterType, $argumentType); + } + + return $constraints; + } + /** * $actual flows into $declared: a property's writable type, a parameter * type, a declared return type, a @var type. @@ -246,6 +310,24 @@ private function observeLowerBound(TemplateArgumentConstraints $constraints, Typ if (!isset($ancestorArguments[$i])) { continue; } + if ($parameterArgument instanceof UnresolvedTemplateArgumentType) { + $template = $parameterReflection->typeMapToList($parameterReflection->getTemplateTypeMap())[$i] ?? null; + if ($template instanceof TemplateType) { + $variance = $parameterReflection->getCallSiteVarianceMap()->getVariance($template->getName()) ?? TemplateTypeVariance::createInvariant(); + $variance = $variance->invariant() ? $template->getVariance() : $variance; + if ($variance->invariant()) { + $constraints = $constraints->withSend($parameterArgument, $ancestorArguments[$i], $variance); + continue; + } + if ($variance->contravariant()) { + $constraints = $constraints->withSend($parameterArgument, $ancestorArguments[$i], TemplateTypeVariance::createCovariant()); + continue; + } + if ($variance->bivariant()) { + continue; + } + } + } $constraints = $this->observeLowerBound($constraints, $parameterArgument, $ancestorArguments[$i]); } diff --git a/src/Analyser/Generics/TemplateArgumentSolver.php b/src/Analyser/Generics/TemplateArgumentSolver.php index d42a81bfb33..076ce7b48b1 100644 --- a/src/Analyser/Generics/TemplateArgumentSolver.php +++ b/src/Analyser/Generics/TemplateArgumentSolver.php @@ -9,8 +9,12 @@ use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\TypeTraverser; +use PHPStan\Type\UnionType; +use function array_filter; use function array_key_exists; use function array_keys; +use function array_merge; +use function array_values; use function count; use function spl_object_id; @@ -32,18 +36,103 @@ public function __construct( /** @return array */ public function solve(): array { + $this->mergeEqualArguments(); foreach (array_keys($this->observations) as $key) { $this->resolveKey($key); } + foreach ($this->representatives as $key => $representative) { + $this->resolutions[$key] = $this->resolveKey($representative); + } return $this->resolutions; } + /** @var array */ + private array $representatives = []; + + private function representative(string $key): string + { + $representative = $this->representatives[$key] ?? $key; + if ($representative === $key) { + return $key; + } + return $this->representatives[$key] = $this->representative($representative); + } + + /** Invariant arguments share one variable until all their bounds are known. */ + private function mergeEqualArguments(): void + { + $ranks = []; + foreach ($this->observations as $key => $observation) { + foreach ($observation['sends'] as [$sent, $variance]) { + if (!$variance->invariant() || !$sent instanceof UnresolvedTemplateArgumentType) { + continue; + } + $other = self::key($sent->getSite(), $sent->getTemplateName()); + if (!isset($this->observations[$other])) { + continue; + } + $left = $this->representative($key); + $right = $this->representative($other); + if ($left === $right) { + continue; + } + $leftRank = $ranks[$left] ?? 0; + $rightRank = $ranks[$right] ?? 0; + if ($leftRank < $rightRank) { + $this->representatives[$left] = $right; + } else { + $this->representatives[$right] = $left; + if ($leftRank === $rightRank) { + $ranks[$left] = $leftRank + 1; + } + } + } + } + if ($this->representatives === []) { + return; + } + $observations = []; + foreach ($this->observations as $key => $observation) { + $representative = $this->representative($key); + $this->representatives[$key] = $representative; + $initial = $observation['initial']; + $observation['initial'] = $initial !== null ? $this->removeSelfBounds($initial, $representative) : null; + $observation['sends'] = array_values(array_filter($observation['sends'], fn (array $send): bool => !$send[0] instanceof UnresolvedTemplateArgumentType + || $this->representative(self::key($send[0]->getSite(), $send[0]->getTemplateName())) !== $representative)); + if (!isset($observations[$representative])) { + $observations[$representative] = $observation; + continue; + } + $merged = $observations[$representative]; + if ($observation['initial'] !== null) { + $merged['initial'] = $merged['initial'] !== null ? TypeCombinator::union($merged['initial'], $observation['initial']) : $observation['initial']; + } + $merged['sends'] = array_merge($merged['sends'], $observation['sends']); + $merged['lowerBounds'] = array_merge($merged['lowerBounds'], $observation['lowerBounds']); + $merged['unconstrainingSend'] = $merged['unconstrainingSend'] || $observation['unconstrainingSend']; + $observations[$representative] = $merged; + } + $this->observations = $observations; + } + + private function removeSelfBounds(Type $type, string $key): Type + { + if ($type instanceof UnresolvedTemplateArgumentType && $this->representative(self::key($type->getSite(), $type->getTemplateName())) === $key) { + return new NeverType(); + } + if ($type instanceof UnionType) { + return $type->traverse(fn (Type $member): Type => $this->removeSelfBounds($member, $key)); + } + return $type; + } + /** @var array */ private array $resolving = []; private function resolveKey(string $key): Type { + $key = $this->representative($key); if (array_key_exists($key, $this->resolutions)) { return $this->resolutions[$key]; } @@ -86,7 +175,7 @@ private function substituteResolutions(Type $type): Type private function substituteMarker(UnresolvedTemplateArgumentType $marker): Type { - $key = self::key($marker->getSite(), $marker->getTemplateName()); + $key = $this->representative(self::key($marker->getSite(), $marker->getTemplateName())); if (array_key_exists($key, $this->observations)) { return $this->resolveKey($key); } @@ -126,6 +215,7 @@ private function resolveObservation(array $observation): Type if (!$templateVariance->covariant() || $acceptsAnything) { $covariantFallback = null; foreach ($observation['sends'] as [$sent, $variance]) { + $sent = $this->substituteResolutions($sent); if ($variance->contravariant()) { // Foo accepts Foo for every X wider than int $lowerBounds[] = $sent; diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index d76c34109bb..4ae14213e1c 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -3172,6 +3172,15 @@ public function processArgs( : $metadataAcceptor; } + if ($resolvedAcceptor !== null && $this->observingTemplateArgumentFrame($scope) !== null) { + $scope = $scope->addTemplateArgumentConstraints($this->templateArgumentObserver->collectCall( + $callLike, + $resolvedAcceptor, + $gatheredTypes, + $callLike instanceof New_ && $calleeReflection instanceof MethodReflection ? $calleeReflection->getDeclaringClass()->getTemplateTypeMap() : null, + )); + } + // The by-ref OUT writeback reads the metadata acceptor: it is selected from // the full argument count (stable variant). When that single acceptor still // carries templates (fast path), its OUT types need generic-resolving from the diff --git a/tests/PHPStan/Analyser/Generics/TemplateArgumentFlowTest.php b/tests/PHPStan/Analyser/Generics/TemplateArgumentFlowTest.php index 829f15d541d..7d4ec97fd49 100644 --- a/tests/PHPStan/Analyser/Generics/TemplateArgumentFlowTest.php +++ b/tests/PHPStan/Analyser/Generics/TemplateArgumentFlowTest.php @@ -5,6 +5,7 @@ use PHPStan\Testing\TypeInferenceTestCase; use PHPUnit\Framework\Attributes\DataProvider; use function array_merge; +use const PHP_VERSION_ID; class TemplateArgumentFlowTest extends TypeInferenceTestCase { @@ -13,6 +14,11 @@ class TemplateArgumentFlowTest extends TypeInferenceTestCase public static function dataConstraintsSurviveControlFlow(): iterable { yield from self::gatherAssertTypes(__DIR__ . '/data/constraint-flow.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/joint-inference.php'); + if (PHP_VERSION_ID < 80000) { + return; + } + yield from self::gatherAssertTypes(__DIR__ . '/../../Rules/Functions/data/joint-inference-named.php'); } #[DataProvider('dataConstraintsSurviveControlFlow')] diff --git a/tests/PHPStan/Analyser/Generics/data/joint-inference-errors.php b/tests/PHPStan/Analyser/Generics/data/joint-inference-errors.php new file mode 100644 index 00000000000..5e49a824ff6 --- /dev/null +++ b/tests/PHPStan/Analyser/Generics/data/joint-inference-errors.php @@ -0,0 +1,86 @@ + $one + * @param Promise<2> $two + */ +function fixedPromises(Promise $one, Promise $two): void +{ + all([$one, $two]); +} + +/** + * @param Box $cat + * @param Box $dog + */ +function fixedBoxes(Box $cat, Box $dog): void +{ + both($cat, $dog); +} + +/** @param Resolver $resolver */ +function requireIntResolver(Resolver $resolver): void +{ +} + +function incompatibleWrite(): void +{ + $one = new Resolver(); + $two = new Resolver(); + all([$one->getPromise(), $two->getPromise()]); + requireIntResolver($one); + $one->resolve(1); + $two->resolve('wrong'); +} + +function incompatibleCallback(): void +{ + $one = new Resolver(); + $two = new Resolver(); + $combined = all([$one->getPromise(), $two->getPromise()]); + $one->resolve(1); + $two->resolve(2); + $combined->onCompletion(static function (string $value): void {}); +} + +/** @param EventCollection|WrittenEvent<'two'>> $events */ +function fixedEvents(EventCollection $events): void +{ + new ContainerEvent($events); +} + +function incompatibleEvent(): void +{ + new ContainerEvent(new EventCollection([new WrittenEvent('one'), new Event()])); +} + +/** + * @template T of int + * @param array> $promises + */ +function allIntegers(array $promises): void +{ +} + +function incompatibleBound(): void +{ + $one = new Resolver(); + $two = new Resolver(); + allIntegers([$one->getPromise(), $two->getPromise()]); + $one->resolve(1); + $two->resolve('wrong'); +} + +function incompatibleRead(): void +{ + $one = new Resolver(); + $two = new Resolver(); + $one->getPromise()->onCompletion(static function (int $value): void {}); + all([$one->getPromise(), $two->getPromise()]); + $one->resolve(1); + $two->resolve('wrong'); +} diff --git a/tests/PHPStan/Analyser/Generics/data/joint-inference.php b/tests/PHPStan/Analyser/Generics/data/joint-inference.php new file mode 100644 index 00000000000..413d9e2740b --- /dev/null +++ b/tests/PHPStan/Analyser/Generics/data/joint-inference.php @@ -0,0 +1,274 @@ + */ + public function getPromise(): Promise + { + throw new LogicException(); + } + + /** @param T $value */ + public function resolve($value): void + { + } + +} + +/** + * @template T + * @param array> $promises + * @return Promise> + */ +function all(array $promises): Promise +{ + throw new LogicException(); +} + +function resolveAfterCombining(): void +{ + $one = new Resolver(); + $two = new Resolver(); + $combined = all([$one->getPromise(), $two->getPromise()]); + $one->resolve(1); + $two->resolve(2); + assertType('JointInference\\Resolver<1|2>', $one); + assertType('JointInference\\Resolver<1|2>', $two); + assertType('JointInference\\Promise>', $combined); + assertNativeType('JointInference\\Resolver', $one); +} + +function resolveBeforeCombining(): void +{ + $one = new Resolver(); + $two = new Resolver(); + $one->resolve(1); + $two->resolve(2); + $combined = all([$two->getPromise(), $one->getPromise()]); + assertType('JointInference\\Resolver<1|2>', $one); + assertType('JointInference\\Resolver<1|2>', $two); + assertType('JointInference\\Promise>', $combined); +} + +class Animal +{ +} + +class Cat extends Animal +{ +} + +class Dog extends Animal +{ +} + +/** @template T */ +class Box +{ + + /** @var T */ + private $value; + + /** @param T $value */ + public function __construct($value) + { + $this->value = $value; + } + + /** @param T $value */ + public function set($value): void + { + $this->value = $value; + } + + /** @return T */ + public function get() + { + return $this->value; + } + +} + +/** + * @template T + * @param Box $a + * @param Box $b + * @return Box + */ +function both(Box $a, Box $b): Box +{ + return $a; +} + +function nominalTypes(): void +{ + $a = new Box(new Cat()); + $b = new Box(new Dog()); + $alias = $a; + $result = both($a, $b); + assertType('JointInference\\Box', $a); + assertType('JointInference\\Box', $alias); + assertType('JointInference\\Box', $result); + assertNativeType('JointInference\\Box', $result); +} + +/** + * @template T + * @param Box $a + * @param Box $b + */ +function consumeBoth(Box $a, Box $b): void +{ +} + +function noGenericReturn(): void +{ + $a = new Box(1); + $b = new Box(2); + consumeBoth($a, $b); + assertType('JointInference\\Box<1|2>', $a); + assertType('JointInference\\Box<1|2>', $b); +} + +class Event +{ +} + +/** @template ID of string */ +class WrittenEvent extends Event +{ + + /** @param ID $id */ + public function __construct($id) + { + } + +} + +/** @template T of Event */ +class EventCollection +{ + + /** @param list $events */ + public function __construct(array $events) + { + } + + /** @param T $event */ + public function add(Event $event): void + { + } + +} + +/** @template ID of string */ +class ContainerEvent +{ + + /** @param EventCollection> $events */ + public function __construct(EventCollection $events) + { + } + +} + +function nestedCollection(): void +{ + $container = new ContainerEvent(new EventCollection([new WrittenEvent('one'), new WrittenEvent('two')])); + assertType("JointInference\\ContainerEvent<'one'|'two'>", $container); +} + +/** + * @template T + * @param Box $a + * @param Box $b + */ +function observeBoxes(Box $a, Box $b): void +{ +} + +function covariantInputsStayIndependent(): void +{ + $a = new Box(1); + $b = new Box('two'); + observeBoxes($a, $b); + assertType('JointInference\\Box<1>', $a); + assertType("JointInference\\Box<'two'>", $b); +} + +function unpackedArguments(): void +{ + $c = new Box(3); + $d = new Box(4); + both(...[$c, $d]); + assertType('JointInference\\Box<3|4>', $c); + assertType('JointInference\\Box<3|4>', $d); +} + +class Combiner +{ + + /** + * @template T + * @param Box $a + * @param Box $b + */ + public function combine(Box $a, Box $b): void + { + } + + /** + * @template T + * @param Box ...$boxes + */ + public static function combineAll(Box ...$boxes): void + { + } + +} + +function methodCalls(Combiner $combiner): void +{ + $a = new Box(1); + $b = new Box(2); + $c = new Box(3); + $combiner->combine($a, $b); + Combiner::combineAll($b, $c); + assertType('JointInference\\Box<1|2|3>', $a); + assertType('JointInference\\Box<1|2|3>', $b); + assertType('JointInference\\Box<1|2|3>', $c); +} + +/** + * @template U of Event + * @extends EventCollection + */ +class ChildCollection extends EventCollection +{ +} + +function inheritedConstructor(): void +{ + $container = new ContainerEvent(new ChildCollection([new WrittenEvent('one'), new WrittenEvent('two')])); + assertType("JointInference\\ContainerEvent<'one'|'two'>", $container); +} diff --git a/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php b/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php index c7dc706e5d4..d518365eaf6 100644 --- a/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php +++ b/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php @@ -71,6 +71,25 @@ protected function getRule(): Rule ); } + public function testJointTemplateInference(): void + { + $this->analyse([__DIR__ . '/../../Analyser/Generics/data/joint-inference.php'], []); + } + + public function testJointTemplateInferenceErrors(): void + { + $this->analyse([__DIR__ . '/../../Analyser/Generics/data/joint-inference-errors.php'], [ + [ + 'Parameter #1 $events of class JointInference\\ContainerEvent constructor expects JointInference\\EventCollection>, JointInference\\EventCollection|JointInference\\WrittenEvent<\'two\'>> given.', + 53, + ], + [ + 'Parameter #1 $events of class JointInference\\ContainerEvent constructor expects JointInference\\EventCollection>, JointInference\\EventCollection given.', + 58, + ], + ]); + } + public function testTemplateArgumentArrow(): void { $this->analyse([__DIR__ . '/data/template-argument-arrow.php'], []); diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index 80c6d38bbc7..227ca8ddf30 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -52,6 +52,38 @@ protected function getRule(): Rule ); } + public function testJointTemplateInference(): void + { + $this->analyse([__DIR__ . '/../../Analyser/Generics/data/joint-inference.php'], []); + } + + #[RequiresPhp('>= 8.0.0')] + public function testJointTemplateInferenceNamedArguments(): void + { + $this->analyse([__DIR__ . '/data/joint-inference-named.php'], []); + } + + public function testJointTemplateInferenceErrors(): void + { + $this->analyse([__DIR__ . '/../../Analyser/Generics/data/joint-inference-errors.php'], [ + [ + 'Parameter #1 $promises of function JointInference\\all expects array>, array{JointInference\\Promise<1>, JointInference\\Promise<2>} given.', + 13, + 'Template type T on class JointInference\\Promise is not covariant. Learn more: https://phpstan.org/blog/whats-up-with-template-covariant', + ], + [ + 'Parameter #1 $a of function JointInference\\both expects JointInference\\Box, JointInference\\Box given.', + 22, + 'Template type T on class JointInference\\Box is not covariant. Learn more: https://phpstan.org/blog/whats-up-with-template-covariant', + ], + [ + 'Parameter #2 $b of function JointInference\\both expects JointInference\\Box, JointInference\\Box given.', + 22, + 'Template type T on class JointInference\\Box is not covariant. Learn more: https://phpstan.org/blog/whats-up-with-template-covariant', + ], + ]); + } + public function testTemplateArgumentReduceIterator(): void { $this->analyse([__DIR__ . '/data/template-argument-reduce-iterator.php'], []); diff --git a/tests/PHPStan/Rules/Functions/data/joint-inference-named.php b/tests/PHPStan/Rules/Functions/data/joint-inference-named.php new file mode 100644 index 00000000000..3bd86f6037c --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/joint-inference-named.php @@ -0,0 +1,18 @@ += 8.0 + +declare(strict_types = 1); + +namespace JointInference; + +use function PHPStan\Testing\assertType; + +require_once __DIR__ . '/../../../Analyser/Generics/data/joint-inference.php'; + +function namedArguments(): void +{ + $a = new Box(1); + $b = new Box(2); + both(b: $b, a: $a); + assertType('JointInference\\Box<1|2>', $a); + assertType('JointInference\\Box<1|2>', $b); +} diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index 6f702181b82..86e276f71f3 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -63,6 +63,40 @@ protected function getRule(): Rule ); } + public function testJointTemplateInference(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/../../Analyser/Generics/data/joint-inference.php'], []); + } + + public function testJointTemplateInferenceErrors(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/../../Analyser/Generics/data/joint-inference-errors.php'], [ + [ + 'Parameter #1 $value of method JointInference\\Resolver::resolve() expects int, string given.', + 37, + ], + [ + 'Parameter #1 $callback of method JointInference\\Promise>::onCompletion() expects Closure(array<1|2>): void, Closure(string): void given.', + 47, + ], + [ + 'Parameter #1 $value of method JointInference\\Resolver::resolve() expects int, string given.', + 75, + ], + [ + 'Parameter #1 $callback of method JointInference\\Promise::onCompletion() expects Closure(1|\'wrong\'): void, Closure(int): void given.', + 82, + 'Type int of parameter #1 $value of passed callable needs to be same or wider than parameter type int|string of accepting callable.', + ], + ]); + } + #[RequiresPhp('< 8.0.0')] public function testIsCallablePhp7(): void {