diff --git a/bin/functionMetadata_original.php b/bin/functionMetadata_original.php index 8f4eb456e53..aea0c2827dc 100644 --- a/bin/functionMetadata_original.php +++ b/bin/functionMetadata_original.php @@ -13,9 +13,13 @@ * the call is pure unless one of the listed callable parameters * (keyed by parameter name) receives an impure callable, e.g. array_map() * whose only side effects come from its 'callback' argument. + * - ['pureUnlessParameterPassedParameters' => array] + * the call is pure unless one of the listed (by-ref out) parameters + * (keyed by parameter name) receives an argument, e.g. str_replace() + * whose only side effect is writing to its optional 'count' argument. */ -/** @var array}> */ +/** @var array}|array{pureUnlessParameterPassedParameters: array}> */ return [ 'abs' => ['hasSideEffects' => false], 'acos' => ['hasSideEffects' => false], @@ -264,7 +268,13 @@ 'output_reset_rewrite_vars' => ['hasSideEffects' => true], 'pclose' => ['hasSideEffects' => true], 'popen' => ['hasSideEffects' => true], + // 'matches'/'subpatterns': PHP 8+ uses the php-8-stubs parameter name, PHP <8 falls + // back to the legacy functionMap.php name. + 'preg_match' => ['pureUnlessParameterPassedParameters' => ['matches' => true, 'subpatterns' => true]], + 'preg_match_all' => ['pureUnlessParameterPassedParameters' => ['matches' => true, 'subpatterns' => true]], + 'preg_replace' => ['pureUnlessParameterPassedParameters' => ['count' => true]], 'preg_replace_callback' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], + 'similar_text' => ['pureUnlessParameterPassedParameters' => ['percent' => true]], 'readfile' => ['hasSideEffects' => true], 'rename' => ['hasSideEffects' => true], 'rewind' => ['hasSideEffects' => true], @@ -272,6 +282,10 @@ 'sprintf' => ['hasSideEffects' => false], 'str_decrement' => ['hasSideEffects' => false], 'str_increment' => ['hasSideEffects' => false], + // 'count'/'replace_count': PHP 8+ uses the php-8-stubs parameter name, PHP <8 falls + // back to the legacy functionMap.php name. + 'str_ireplace' => ['pureUnlessParameterPassedParameters' => ['count' => true, 'replace_count' => true]], + 'str_replace' => ['pureUnlessParameterPassedParameters' => ['count' => true, 'replace_count' => true]], 'symlink' => ['hasSideEffects' => true], 'time' => ['hasSideEffects' => true], 'tempnam' => ['hasSideEffects' => true], diff --git a/bin/generate-function-metadata.php b/bin/generate-function-metadata.php index f5ef1c5101c..f3a4a66f664 100755 --- a/bin/generate-function-metadata.php +++ b/bin/generate-function-metadata.php @@ -119,7 +119,7 @@ public function enterNode(Node $node) ); } - /** @var array}> $metadata */ + /** @var array, pureUnlessParameterPassedParameters?: array}> $metadata */ $metadata = require __DIR__ . '/functionMetadata_original.php'; foreach ($visitor->functions as $functionName) { if (array_key_exists($functionName, $metadata)) { @@ -134,6 +134,14 @@ public function enterNode(Node $node) continue; } + + if (isset($metadata[$functionName]['pureUnlessParameterPassedParameters'])) { + $metadata[$functionName] = [ + 'pureUnlessParameterPassedParameters' => $metadata[$functionName]['pureUnlessParameterPassedParameters'], + ]; + + continue; + } } $metadata[$functionName] = ['hasSideEffects' => false]; } @@ -192,9 +200,12 @@ public function enterNode(Node $node) * - ['pureUnlessCallableIsImpureParameters' => array] - pure unless * one of the listed callable parameters (keyed by parameter name) receives an * impure callable, e.g. array_map()'s 'callback'. + * - ['pureUnlessParameterPassedParameters' => array] - pure unless + * one of the listed (by-ref out) parameters (keyed by parameter name) receives + * an argument, e.g. str_replace()'s 'replace_count'. */ -/** @var array}> */ +/** @var array}|array{pureUnlessParameterPassedParameters: array}> */ return [ %s ]; @@ -216,6 +227,20 @@ public function enterNode(Node $node) ), ), ]; + $encodePureUnlessParameterPassedParameters = static fn (array $meta) => [ + $escape('pureUnlessParameterPassedParameters'), + sprintf( + '[%s]', + implode( + ' ,', + array_map( + static fn ($key, $param) => sprintf('%s => %s', $escape($key), $escape($param)), + array_keys($meta['pureUnlessParameterPassedParameters']), + $meta['pureUnlessParameterPassedParameters'], + ), + ), + ), + ]; foreach ($metadata as $name => $meta) { $content .= sprintf( @@ -224,6 +249,7 @@ public function enterNode(Node $node) ...match (true) { isset($meta['hasSideEffects']) => $encodeHasSideEffects($meta), isset($meta['pureUnlessCallableIsImpureParameters']) => $encodePureUnlessCallableIsImpureParameters($meta), + isset($meta['pureUnlessParameterPassedParameters']) => $encodePureUnlessParameterPassedParameters($meta), default => throw new ShouldNotHappenException($escape($meta)), }, ); diff --git a/resources/functionMetadata.php b/resources/functionMetadata.php index af546d10642..1204e71091a 100644 --- a/resources/functionMetadata.php +++ b/resources/functionMetadata.php @@ -19,9 +19,12 @@ * - ['pureUnlessCallableIsImpureParameters' => array] - pure unless * one of the listed callable parameters (keyed by parameter name) receives an * impure callable, e.g. array_map()'s 'callback'. + * - ['pureUnlessParameterPassedParameters' => array] - pure unless + * one of the listed (by-ref out) parameters (keyed by parameter name) receives + * an argument, e.g. str_replace()'s 'count'. */ -/** @var array}> */ +/** @var array}|array{pureUnlessParameterPassedParameters: array}> */ return [ 'BackedEnum::from' => ['hasSideEffects' => false], 'BackedEnum::tryFrom' => ['hasSideEffects' => false], @@ -1634,7 +1637,10 @@ 'preg_grep' => ['hasSideEffects' => false], 'preg_last_error' => ['hasSideEffects' => true], 'preg_last_error_msg' => ['hasSideEffects' => true], + 'preg_match' => ['pureUnlessParameterPassedParameters' => ['matches' => true, 'subpatterns' => true]], + 'preg_match_all' => ['pureUnlessParameterPassedParameters' => ['matches' => true, 'subpatterns' => true]], 'preg_quote' => ['hasSideEffects' => false], + 'preg_replace' => ['pureUnlessParameterPassedParameters' => ['count' => true]], 'preg_replace_callback' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], 'preg_split' => ['hasSideEffects' => false], 'property_exists' => ['hasSideEffects' => false], @@ -1666,6 +1672,7 @@ 'rtrim' => ['hasSideEffects' => false], 'sha1' => ['hasSideEffects' => false], 'sha1_file' => ['hasSideEffects' => true], + 'similar_text' => ['pureUnlessParameterPassedParameters' => ['percent' => true]], 'sin' => ['hasSideEffects' => false], 'sinh' => ['hasSideEffects' => false], 'sizeof' => ['hasSideEffects' => false], @@ -1680,8 +1687,10 @@ 'str_ends_with' => ['hasSideEffects' => false], 'str_getcsv' => ['hasSideEffects' => false], 'str_increment' => ['hasSideEffects' => false], + 'str_ireplace' => ['pureUnlessParameterPassedParameters' => ['count' => true, 'replace_count' => true]], 'str_pad' => ['hasSideEffects' => false], 'str_repeat' => ['hasSideEffects' => false], + 'str_replace' => ['pureUnlessParameterPassedParameters' => ['count' => true, 'replace_count' => true]], 'str_rot13' => ['hasSideEffects' => false], 'str_split' => ['hasSideEffects' => false], 'str_starts_with' => ['hasSideEffects' => false], diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 111fe38a682..93bda59a5a2 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -266,6 +266,17 @@ private function processConstructorReflection(string $className, New_ $expr, Mut if ($verdict !== null && $verdict->no()) { $certain = true; } + + if (!$certain) { + $passedVerdict = SimpleImpurePoint::resolvePureUnlessParameterPassedVerdict($parametersAcceptor, $expr->getArgs()); + if ($passedVerdict !== null && $passedVerdict->yes()) { + return [$constructorReflection, $classReflection, $parametersAcceptor, $impurePoints]; + } + if ($passedVerdict !== null && $passedVerdict->no()) { + $certain = true; + } + } + $impurePoints[] = new ImpurePoint( $scope, $expr, diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index bc65d25f41a..9b469079bdc 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1556,6 +1556,7 @@ public function enterTrait(ClassReflection $traitReflection): self * @param array $immediatelyInvokedCallableParameters * @param array $phpDocClosureThisTypeParameters * @param array $phpDocPureUnlessCallableIsImpureParameters + * @param array $phpDocPureUnlessParameterPassedParameters */ public function enterClassMethod( Node\Stmt\ClassMethod $classMethod, @@ -1578,6 +1579,7 @@ public function enterClassMethod( bool $isConstructor = false, ?ResolvedPhpDocBlock $resolvedPhpDocBlock = null, array $phpDocPureUnlessCallableIsImpureParameters = [], + array $phpDocPureUnlessParameterPassedParameters = [], ): self { if (!$this->isInClass()) { @@ -1614,6 +1616,7 @@ public function enterClassMethod( $isConstructor, $this->attributeReflectionFactory->fromAttrGroups($classMethod->attrGroups, InitializerExprContext::fromStubParameter($this->getClassReflection()->getName(), $this->getFile(), $classMethod)), $phpDocPureUnlessCallableIsImpureParameters, + $phpDocPureUnlessParameterPassedParameters, ), !$classMethod->isStatic(), ); @@ -1704,6 +1707,7 @@ public function enterPropertyHook( false, $this->attributeReflectionFactory->fromAttrGroups($hook->attrGroups, InitializerExprContext::fromStubParameter($this->getClassReflection()->getName(), $this->getFile(), $hook)), [], + [], ), true, ); @@ -1781,6 +1785,7 @@ private function getParameterAttributes(ClassMethod|Function_|PropertyHook $func * @param array $immediatelyInvokedCallableParameters * @param array $phpDocClosureThisTypeParameters * @param array $pureUnlessCallableIsImpureParameters + * @param array $pureUnlessParameterPassedParameters */ public function enterFunction( Node\Stmt\Function_ $function, @@ -1799,6 +1804,7 @@ public function enterFunction( array $immediatelyInvokedCallableParameters = [], array $phpDocClosureThisTypeParameters = [], array $pureUnlessCallableIsImpureParameters = [], + array $pureUnlessParameterPassedParameters = [], ): self { return $this->enterFunctionLike( @@ -1825,6 +1831,7 @@ public function enterFunction( $phpDocClosureThisTypeParameters, $this->attributeReflectionFactory->fromAttrGroups($function->attrGroups, InitializerExprContext::fromStubParameter(null, $this->getFile(), $function)), $pureUnlessCallableIsImpureParameters, + $pureUnlessParameterPassedParameters, ), false, ); diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index b979b61b7a5..73e97172d68 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -875,7 +875,7 @@ public function processStmtNode( $throwPoints = []; $impurePoints = []; $this->processAttributeGroups($stmt, $stmt->attrGroups, $scope, $storage, $nodeCallback); - [$templateTypeMap, $phpDocParameterTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $phpDocReturnType, $phpDocThrowType, $deprecatedDescription, $isDeprecated, $isInternal, $isFinal, $isPure, $acceptsNamedArguments, $isReadOnly, $phpDocComment, $asserts, $selfOutType, $phpDocParameterOutTypes, , , , $pureUnlessCallableIsImpureParameters] = $this->getPhpDocs($scope, $stmt); + [$templateTypeMap, $phpDocParameterTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $phpDocReturnType, $phpDocThrowType, $deprecatedDescription, $isDeprecated, $isInternal, $isFinal, $isPure, $acceptsNamedArguments, $isReadOnly, $phpDocComment, $asserts, $selfOutType, $phpDocParameterOutTypes, , , , $pureUnlessCallableIsImpureParameters, $pureUnlessParameterPassedParameters] = $this->getPhpDocs($scope, $stmt); foreach ($stmt->params as $param) { $this->processParamNode($stmt, $param, $scope, $storage, $nodeCallback); @@ -913,6 +913,7 @@ public function processStmtNode( $isConstructor, null, $pureUnlessCallableIsImpureParameters, + $pureUnlessParameterPassedParameters, ); if (!$scope->isInClass()) { @@ -4943,7 +4944,7 @@ private function processNodesForCalledMethod($node, ExpressionResultStorage $sto } /** - * @return array{TemplateTypeMap, array, array, array, ?Type, ?Type, ?string, bool, bool, bool, bool|null, bool, bool, string|null, Assertions, ?Type, array, array<(string|int), VarTag>, bool, ?ResolvedPhpDocBlock, array} + * @return array{TemplateTypeMap, array, array, array, ?Type, ?Type, ?string, bool, bool, bool, bool|null, bool, bool, string|null, Assertions, ?Type, array, array<(string|int), VarTag>, bool, ?ResolvedPhpDocBlock, array, array} */ public function getPhpDocs(Scope $scope, Node\FunctionLike|Node\Stmt\Property $node): array { @@ -4974,6 +4975,7 @@ public function getPhpDocs(Scope $scope, Node\FunctionLike|Node\Stmt\Property $n $functionName = null; $phpDocParameterOutTypes = []; $phpDocPureUnlessCallableIsImpureParameters = []; + $phpDocPureUnlessParameterPassedParameters = []; if ($node instanceof Node\Stmt\ClassMethod) { if (!$scope->isInClass()) { @@ -5108,6 +5110,7 @@ public function getPhpDocs(Scope $scope, Node\FunctionLike|Node\Stmt\Property $n $selfOutType = $resolvedPhpDoc->getSelfOutTag() !== null ? $resolvedPhpDoc->getSelfOutTag()->getType() : null; $varTags = $resolvedPhpDoc->getVarTags(); $phpDocPureUnlessCallableIsImpureParameters = $resolvedPhpDoc->getParamsPureUnlessCallableIsImpure(); + $phpDocPureUnlessParameterPassedParameters = $resolvedPhpDoc->getParamsPureUnlessParameterPassed(); } if ($acceptsNamedArguments && $scope->isInClass()) { @@ -5131,7 +5134,7 @@ public function getPhpDocs(Scope $scope, Node\FunctionLike|Node\Stmt\Property $n } } - return [$templateTypeMap, $phpDocParameterTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $phpDocReturnType, $phpDocThrowType, $deprecatedDescription, $isDeprecated, $isInternal, $isFinal, $isPure, $acceptsNamedArguments, $isReadOnly, $docComment, $asserts, $selfOutType, $phpDocParameterOutTypes, $varTags, $isAllowedPrivateMutation, $resolvedPhpDoc, $phpDocPureUnlessCallableIsImpureParameters]; + return [$templateTypeMap, $phpDocParameterTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $phpDocReturnType, $phpDocThrowType, $deprecatedDescription, $isDeprecated, $isInternal, $isFinal, $isPure, $acceptsNamedArguments, $isReadOnly, $docComment, $asserts, $selfOutType, $phpDocParameterOutTypes, $varTags, $isAllowedPrivateMutation, $resolvedPhpDoc, $phpDocPureUnlessCallableIsImpureParameters, $phpDocPureUnlessParameterPassedParameters]; } private function transformStaticType(ClassReflection $declaringClass, Type $type): Type diff --git a/src/PhpDoc/PhpDocNodeResolver.php b/src/PhpDoc/PhpDocNodeResolver.php index 2304edc1490..048031ae7fb 100644 --- a/src/PhpDoc/PhpDocNodeResolver.php +++ b/src/PhpDoc/PhpDocNodeResolver.php @@ -403,6 +403,22 @@ public function resolveParamPureUnlessCallableIsImpure(PhpDocNode $phpDocNode): return $parameters; } + /** + * @return array + */ + public function resolveParamPureUnlessParameterPassed(PhpDocNode $phpDocNode): array + { + $parameters = []; + foreach (['@pure-unless-parameter-passed', '@phpstan-pure-unless-parameter-passed'] as $tagName) { + foreach ($phpDocNode->getPureUnlessParameterIsPassedTagValues($tagName) as $tag) { + $parameterName = substr($tag->parameterName, 1); + $parameters[$parameterName] = true; + } + } + + return $parameters; + } + /** * @return array */ diff --git a/src/PhpDoc/ResolvedPhpDocBlock.php b/src/PhpDoc/ResolvedPhpDocBlock.php index c00634c963f..ba4e6d7bf63 100644 --- a/src/PhpDoc/ResolvedPhpDocBlock.php +++ b/src/PhpDoc/ResolvedPhpDocBlock.php @@ -99,6 +99,9 @@ final class ResolvedPhpDocBlock /** @var array|false */ private array|false $paramsPureUnlessCallableIsImpure = false; + /** @var array|false */ + private array|false $paramsPureUnlessParameterPassed = false; + /** @var array|false */ private array|false $paramClosureThisTags = false; @@ -224,6 +227,7 @@ public static function createEmpty(): self $self->paramOutTags = []; $self->paramsImmediatelyInvokedCallable = []; $self->paramsPureUnlessCallableIsImpure = []; + $self->paramsPureUnlessParameterPassed = []; $self->paramClosureThisTags = []; $self->returnTag = null; $self->throwsTag = null; @@ -281,6 +285,7 @@ public function merge(ResolvedPhpDocBlock $parent, InheritedPhpDocParameterMappi $result->paramOutTags = self::mergeParamOutTags($this->getParamOutTags(), $parent, $parameterMapping, $parentClass); $result->paramsImmediatelyInvokedCallable = self::mergeParamsImmediatelyInvokedCallable($this->getParamsImmediatelyInvokedCallable(), $parent, $parameterMapping); $result->paramsPureUnlessCallableIsImpure = self::mergeParamsPureUnlessCallableIsImpure($this->getParamsPureUnlessCallableIsImpure(), $parent, $parameterMapping); + $result->paramsPureUnlessParameterPassed = self::mergeParamsPureUnlessParameterPassed($this->getParamsPureUnlessParameterPassed(), $parent, $parameterMapping); $result->paramClosureThisTags = self::mergeParamClosureThisTags($this->getParamClosureThisTags(), $parent, $parameterMapping, $parentClass); $result->returnTag = self::mergeReturnTags($this->getReturnTag(), $declaringClass, $parent, $parameterMapping, $parentClass); $result->throwsTag = self::mergeThrowsTags($this->getThrowsTag(), $parent); @@ -601,6 +606,18 @@ public function getParamsPureUnlessCallableIsImpure(): array return $this->paramsPureUnlessCallableIsImpure; } + /** + * @return array + */ + public function getParamsPureUnlessParameterPassed(): array + { + if ($this->paramsPureUnlessParameterPassed === false) { + $this->paramsPureUnlessParameterPassed = $this->phpDocNodeResolver->resolveParamPureUnlessParameterPassed($this->phpDocNode); + } + + return $this->paramsPureUnlessParameterPassed; + } + /** * @return array */ @@ -1130,6 +1147,34 @@ private static function mergeOneParentParamPureUnlessCallableIsImpure(array $par return $paramsPureUnlessCallableIsImpure; } + /** + * @param array $paramsPureUnlessParameterPassed + * @return array + */ + private static function mergeParamsPureUnlessParameterPassed(array $paramsPureUnlessParameterPassed, self $parent, InheritedPhpDocParameterMapping $parameterMapping): array + { + return self::mergeOneParentParamPureUnlessParameterPassed($paramsPureUnlessParameterPassed, $parent, $parameterMapping); + } + + /** + * @param array $paramsPureUnlessParameterPassed + * @return array + */ + private static function mergeOneParentParamPureUnlessParameterPassed(array $paramsPureUnlessParameterPassed, self $parent, InheritedPhpDocParameterMapping $parameterMapping): array + { + $parentPureUnlessParameterPassed = $parameterMapping->transformArrayKeysWithParameterNameMapping($parent->getParamsPureUnlessParameterPassed()); + + foreach ($parentPureUnlessParameterPassed as $name => $parentIsPureUnlessParameterPassed) { + if (array_key_exists($name, $paramsPureUnlessParameterPassed)) { + continue; + } + + $paramsPureUnlessParameterPassed[$name] = $parentIsPureUnlessParameterPassed; + } + + return $paramsPureUnlessParameterPassed; + } + /** * @param array $paramsClosureThisTags * @return array diff --git a/src/Reflection/Annotations/AnnotationsMethodParameterReflection.php b/src/Reflection/Annotations/AnnotationsMethodParameterReflection.php index 60849ddc98e..7becf4b758a 100644 --- a/src/Reflection/Annotations/AnnotationsMethodParameterReflection.php +++ b/src/Reflection/Annotations/AnnotationsMethodParameterReflection.php @@ -97,4 +97,9 @@ public function isPureUnlessCallableIsImpureParameter(): TrinaryLogic return TrinaryLogic::createNo(); } + public function isPureUnlessParameterPassedParameter(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + } diff --git a/src/Reflection/BetterReflection/BetterReflectionProvider.php b/src/Reflection/BetterReflection/BetterReflectionProvider.php index 68bee839746..eb527182051 100644 --- a/src/Reflection/BetterReflection/BetterReflectionProvider.php +++ b/src/Reflection/BetterReflection/BetterReflectionProvider.php @@ -295,6 +295,7 @@ private function getCustomFunction(string $functionName): PhpFunctionReflection $phpDocParameterImmediatelyInvokedCallable = []; $phpDocParameterClosureThisTypeTags = []; $phpDocParameterPureUnlessCallableIsImpure = []; + $phpDocParameterPureUnlessParameterPassed = []; $resolvedPhpDoc = $this->stubPhpDocProvider->findFunctionPhpDoc($reflectionFunction->getName(), array_map(static fn (ReflectionParameter $parameter): string => $parameter->getName(), $reflectionFunction->getParameters())); if ($resolvedPhpDoc === null && $reflectionFunction->getFileName() !== false && $reflectionFunction->getDocComment() !== false) { @@ -322,6 +323,7 @@ private function getCustomFunction(string $functionName): PhpFunctionReflection $phpDocParameterImmediatelyInvokedCallable = $resolvedPhpDoc->getParamsImmediatelyInvokedCallable(); $phpDocParameterClosureThisTypeTags = $resolvedPhpDoc->getParamClosureThisTags(); $phpDocParameterPureUnlessCallableIsImpure = $resolvedPhpDoc->getParamsPureUnlessCallableIsImpure(); + $phpDocParameterPureUnlessParameterPassed = $resolvedPhpDoc->getParamsPureUnlessParameterPassed(); } $lowerCasedFunctionName = strtolower($reflectionFunction->getName()); @@ -334,6 +336,13 @@ private function getCustomFunction(string $functionName): PhpFunctionReflection $phpDocParameterPureUnlessCallableIsImpure[$parameterName] = $isPureUnlessCallableIsImpure; } + foreach ($functionMetadata['pureUnlessParameterPassedParameters'] ?? [] as $parameterName => $isPureUnlessParameterPassed) { + if (($phpDocParameterPureUnlessParameterPassed[$parameterName] ?? false) === true) { + continue; + } + + $phpDocParameterPureUnlessParameterPassed[$parameterName] = $isPureUnlessParameterPassed; + } } return $this->functionReflectionFactory->create( @@ -355,6 +364,7 @@ private function getCustomFunction(string $functionName): PhpFunctionReflection array_map(static fn (ParamClosureThisTag $tag): Type => $tag->getType(), $phpDocParameterClosureThisTypeTags), $this->attributeReflectionFactory->fromNativeReflection($reflectionFunction->getAttributes(), InitializerExprContext::fromFunction($reflectionFunction->getName(), $reflectionFunction->getFileName() !== false ? $reflectionFunction->getFileName() : null)), $phpDocParameterPureUnlessCallableIsImpure, + $phpDocParameterPureUnlessParameterPassed, ); } diff --git a/src/Reflection/Callables/SimpleImpurePoint.php b/src/Reflection/Callables/SimpleImpurePoint.php index 95067a61380..e8f23bf7998 100644 --- a/src/Reflection/Callables/SimpleImpurePoint.php +++ b/src/Reflection/Callables/SimpleImpurePoint.php @@ -74,6 +74,20 @@ public static function createFromVariant(FunctionReflection|ExtendedMethodReflec } } + if (!$certain && $scope !== null && $variant !== null) { + $passedVerdict = self::resolvePureUnlessParameterPassedVerdict($variant, $args); + if ($passedVerdict !== null) { + if ($passedVerdict->yes()) { + // None of the @pure-unless-parameter-passed by-ref parameters + // received an argument, so the call is pure. + return null; + } + if ($passedVerdict->no()) { + $certain = true; + } + } + } + if ($function instanceof FunctionReflection) { if (isset(self::SIDE_EFFECT_FLIP_PARAMETERS[$function->getName()]) && $scope !== null) { [ @@ -203,6 +217,79 @@ public static function resolvePureUnlessCallableIsImpureVerdict(ParametersAccept return $verdict; } + /** + * Purity verdict for parameters flagged with @pure-unless-parameter-passed: + * the call stays pure as long as none of those (by-ref out) parameters + * received an argument. Returns Yes when no flagged parameter was passed, + * No when at least one was, and null when the variant has no such parameters + * (so the caller keeps its current behavior). + * + * @param Arg[] $args + */ + public static function resolvePureUnlessParameterPassedVerdict(ParametersAcceptor $variant, array $args): ?TrinaryLogic + { + $parameters = $variant->getParameters(); + $verdict = null; + + foreach ($parameters as $parameterIndex => $parameter) { + if (!$parameter instanceof ExtendedParameterReflection) { + continue; + } + if ($parameter->isPureUnlessParameterPassedParameter()->no()) { + continue; + } + + $verdict ??= TrinaryLogic::createYes(); + + $matchedArg = null; + $hasUnpackedArg = false; + $hasNamedParameter = false; + foreach ($args as $i => $arg) { + if ($arg->unpack) { + $hasUnpackedArg = true; + continue; + } + + if ($arg->name !== null) { + $hasNamedParameter = true; + if ($arg->name->name === $parameter->getName()) { + $matchedArg = $arg; + break; + } + + continue; + } + + if (!$hasNamedParameter && $i === $parameterIndex) { + $matchedArg = $arg; + break; + } + } + + if ($matchedArg === null) { + if ($hasUnpackedArg) { + // An unpacked argument list (...$args) might supply the flagged + // by-ref parameter, so we cannot be sure the call stays pure. + $verdict = $verdict->and(TrinaryLogic::createMaybe()); + } + + continue; + } + + if ($parameter->isPureUnlessParameterPassedParameter()->yes()) { + $verdict = $verdict->and(TrinaryLogic::createNo()); + continue; + } + + // The flag itself is uncertain (e.g. only one variant of a union type + // declares @pure-unless-parameter-passed), so passing an argument here + // only makes the call possibly impure, not certainly impure. + $verdict = $verdict->and(TrinaryLogic::createMaybe()); + } + + return $verdict; + } + /** @return ImpurePointIdentifier */ public function getIdentifier(): string { diff --git a/src/Reflection/ExtendedParameterReflection.php b/src/Reflection/ExtendedParameterReflection.php index 3f2df446330..8ce8e77ab86 100644 --- a/src/Reflection/ExtendedParameterReflection.php +++ b/src/Reflection/ExtendedParameterReflection.php @@ -38,4 +38,6 @@ public function checkAllowedConstants(array $constants): AllowedConstantsResult; public function isPureUnlessCallableIsImpureParameter(): TrinaryLogic; + public function isPureUnlessParameterPassedParameter(): TrinaryLogic; + } diff --git a/src/Reflection/FunctionReflectionFactory.php b/src/Reflection/FunctionReflectionFactory.php index 58224858714..3ff2adcbbbe 100644 --- a/src/Reflection/FunctionReflectionFactory.php +++ b/src/Reflection/FunctionReflectionFactory.php @@ -17,6 +17,7 @@ interface FunctionReflectionFactory * @param array $phpDocParameterClosureThisTypes * @param list $attributes * @param array $phpDocParameterPureUnlessCallableIsImpure + * @param array $phpDocParameterPureUnlessParameterPassed */ public function create( ReflectionFunction $reflection, @@ -37,6 +38,7 @@ public function create( array $phpDocParameterClosureThisTypes, array $attributes, array $phpDocParameterPureUnlessCallableIsImpure, + array $phpDocParameterPureUnlessParameterPassed, ): PhpFunctionReflection; } diff --git a/src/Reflection/GenericParametersAcceptorResolver.php b/src/Reflection/GenericParametersAcceptorResolver.php index c1b719387eb..f2c70606870 100644 --- a/src/Reflection/GenericParametersAcceptorResolver.php +++ b/src/Reflection/GenericParametersAcceptorResolver.php @@ -133,6 +133,7 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc [], null, TrinaryLogic::createNo(), + TrinaryLogic::createNo(), ), $parameters), $parametersAcceptor->isVariadic(), $returnType, diff --git a/src/Reflection/Native/ExtendedNativeParameterReflection.php b/src/Reflection/Native/ExtendedNativeParameterReflection.php index d4957240fd1..0c08b58b709 100644 --- a/src/Reflection/Native/ExtendedNativeParameterReflection.php +++ b/src/Reflection/Native/ExtendedNativeParameterReflection.php @@ -32,6 +32,7 @@ public function __construct( private array $attributes, private ?ParameterAllowedConstants $allowedConstants, private TrinaryLogic $pureUnlessCallableIsImpureParameter, + private TrinaryLogic $pureUnlessParameterPassedParameter, ) { } @@ -120,4 +121,9 @@ public function isPureUnlessCallableIsImpureParameter(): TrinaryLogic return $this->pureUnlessCallableIsImpureParameter; } + public function isPureUnlessParameterPassedParameter(): TrinaryLogic + { + return $this->pureUnlessParameterPassedParameter; + } + } diff --git a/src/Reflection/ParametersAcceptorSelector.php b/src/Reflection/ParametersAcceptorSelector.php index f0e50750602..bdfc3744702 100644 --- a/src/Reflection/ParametersAcceptorSelector.php +++ b/src/Reflection/ParametersAcceptorSelector.php @@ -769,6 +769,7 @@ public static function combineAcceptors(array $acceptors): ExtendedParametersAcc $parameter instanceof ExtendedParameterReflection ? $parameter->getAttributes() : [], $parameter instanceof ExtendedParameterReflection ? $parameter->getAllowedConstants() : null, $parameter instanceof ExtendedParameterReflection ? $parameter->isPureUnlessCallableIsImpureParameter() : TrinaryLogic::createNo(), + $parameter instanceof ExtendedParameterReflection ? $parameter->isPureUnlessParameterPassedParameter() : TrinaryLogic::createNo(), ); continue; } @@ -827,6 +828,10 @@ public static function combineAcceptors(array $acceptors): ExtendedParametersAcc $rightPureUnless = $parameter instanceof ExtendedParameterReflection ? $parameter->isPureUnlessCallableIsImpureParameter() : TrinaryLogic::createNo(); $pureUnlessCallableIsImpureParameter = $leftPureUnless->equals($rightPureUnless) ? $leftPureUnless : TrinaryLogic::createMaybe(); + $leftPureUnlessParameterPassed = $parameters[$i]->isPureUnlessParameterPassedParameter(); + $rightPureUnlessParameterPassed = $parameter instanceof ExtendedParameterReflection ? $parameter->isPureUnlessParameterPassedParameter() : TrinaryLogic::createNo(); + $pureUnlessParameterPassedParameter = $leftPureUnlessParameterPassed->equals($rightPureUnlessParameterPassed) ? $leftPureUnlessParameterPassed : TrinaryLogic::createMaybe(); + $parameters[$i] = new ExtendedDummyParameter( $parameters[$i]->getName() !== $parameter->getName() ? sprintf('%s|%s', $parameters[$i]->getName(), $parameter->getName()) : $parameter->getName(), $type, @@ -842,6 +847,7 @@ public static function combineAcceptors(array $acceptors): ExtendedParametersAcc $attributes, $allowedConstants, $pureUnlessCallableIsImpureParameter, + $pureUnlessParameterPassedParameter, ); if ($isVariadic) { @@ -944,6 +950,7 @@ private static function wrapParameter(ParameterReflection $parameter): ExtendedP [], null, TrinaryLogic::createNo(), + TrinaryLogic::createNo(), ); } @@ -1273,6 +1280,7 @@ private static function overrideParameterType(ParameterReflection $original, Typ $wrapped->getAttributes(), $wrapped->getAllowedConstants(), $wrapped->isPureUnlessCallableIsImpureParameter(), + $wrapped->isPureUnlessParameterPassedParameter(), ); } diff --git a/src/Reflection/Php/ClosureCallMethodReflection.php b/src/Reflection/Php/ClosureCallMethodReflection.php index d42361970e6..e031576c097 100644 --- a/src/Reflection/Php/ClosureCallMethodReflection.php +++ b/src/Reflection/Php/ClosureCallMethodReflection.php @@ -99,8 +99,9 @@ public function getVariants(): array null, [], null, - // pure-unless-callable-is-impure is not threaded here: a closure's own - // parameters cannot carry the tag. + // pure-unless-callable-is-impure and pure-unless-parameter-passed are not + // threaded here: a closure's own parameters cannot carry either tag. + TrinaryLogic::createNo(), TrinaryLogic::createNo(), ), $parameters), $this->closureType->isVariadic(), diff --git a/src/Reflection/Php/ExitFunctionReflection.php b/src/Reflection/Php/ExitFunctionReflection.php index 9b771e11bd0..a3609465e4a 100644 --- a/src/Reflection/Php/ExitFunctionReflection.php +++ b/src/Reflection/Php/ExitFunctionReflection.php @@ -61,6 +61,7 @@ public function getVariants(): array [], null, TrinaryLogic::createNo(), + TrinaryLogic::createNo(), ), ], false, diff --git a/src/Reflection/Php/ExtendedDummyParameter.php b/src/Reflection/Php/ExtendedDummyParameter.php index adf1315738f..30434679271 100644 --- a/src/Reflection/Php/ExtendedDummyParameter.php +++ b/src/Reflection/Php/ExtendedDummyParameter.php @@ -32,6 +32,7 @@ public function __construct( private array $attributes, private ?ParameterAllowedConstants $allowedConstants, private TrinaryLogic $pureUnlessCallableIsImpureParameter, + private TrinaryLogic $pureUnlessParameterPassedParameter, ) { parent::__construct($name, $type, $optional, $passedByReference, $variadic, $defaultValue); @@ -91,4 +92,9 @@ public function isPureUnlessCallableIsImpureParameter(): TrinaryLogic return $this->pureUnlessCallableIsImpureParameter; } + public function isPureUnlessParameterPassedParameter(): TrinaryLogic + { + return $this->pureUnlessParameterPassedParameter; + } + } diff --git a/src/Reflection/Php/PhpClassReflectionExtension.php b/src/Reflection/Php/PhpClassReflectionExtension.php index 16a725cf433..042604e219c 100644 --- a/src/Reflection/Php/PhpClassReflectionExtension.php +++ b/src/Reflection/Php/PhpClassReflectionExtension.php @@ -885,6 +885,7 @@ public function createUserlandMethodReflection(ClassReflection $fileDeclaringCla $isPure = null; $pureUnlessCallableIsImpureParameters = []; + $pureUnlessParameterPassedParameters = []; if ($actualDeclaringClass->isBuiltin() || $actualDeclaringClass->isEnum()) { foreach (array_keys($actualDeclaringClass->getAncestors()) as $className) { if ($this->signatureMapProvider->hasMethodMetadata($className, $methodReflection->getName())) { @@ -892,6 +893,7 @@ public function createUserlandMethodReflection(ClassReflection $fileDeclaringCla $hasSideEffects = $methodMetadata['hasSideEffects'] ?? true; $isPure = !$hasSideEffects; $pureUnlessCallableIsImpureParameters += $methodMetadata['pureUnlessCallableIsImpureParameters'] ?? []; + $pureUnlessParameterPassedParameters += $methodMetadata['pureUnlessParameterPassedParameters'] ?? []; break; } @@ -917,6 +919,9 @@ public function createUserlandMethodReflection(ClassReflection $fileDeclaringCla foreach ($resolvedPhpDoc->getParamsPureUnlessCallableIsImpure() as $paramName => $isPureUnlessCallableIsImpure) { $pureUnlessCallableIsImpureParameters[$paramName] = $isPureUnlessCallableIsImpure; } + foreach ($resolvedPhpDoc->getParamsPureUnlessParameterPassed() as $paramName => $isPureUnlessParameterPassed) { + $pureUnlessParameterPassedParameters[$paramName] = $isPureUnlessParameterPassed; + } $phpDocReturnType = $this->getPhpDocReturnType($phpDocBlockClassReflection, $resolvedPhpDoc, $nativeReturnType); $phpDocThrowType = $resolvedPhpDoc->getThrowsTag() !== null ? $resolvedPhpDoc->getThrowsTag()->getType() : null; foreach ($resolvedPhpDoc->getParamTags() as $paramName => $paramTag) { @@ -997,6 +1002,7 @@ public function createUserlandMethodReflection(ClassReflection $fileDeclaringCla $acceptsNamedArguments, $this->attributeReflectionFactory->fromNativeReflection($methodReflection->getAttributes(), InitializerExprContext::fromClassMethod($actualDeclaringClass->getName(), $declaringTraitName, $methodReflection->getName(), $actualDeclaringClass->getFileName())), $pureUnlessCallableIsImpureParameters, + $pureUnlessParameterPassedParameters, ); } @@ -1065,8 +1071,10 @@ private function createNativeMethodVariant( $closureThisType, [], $this->allowedConstantsMapProvider->getForMethodParameter($declaringClassName, $methodName, $parameterSignature->getName()), - // pure-unless-callable-is-impure is not threaded here because no built-in method - // carries it (there are no Class::method entries in functionMetadata.php). + // pure-unless-callable-is-impure and pure-unless-parameter-passed are not + // threaded here because no built-in method carries either tag (there are no + // Class::method entries in functionMetadata.php). + TrinaryLogic::createNo(), TrinaryLogic::createNo(), ); } @@ -1175,7 +1183,7 @@ private function inferAndCachePropertyTypes( $classScope = $classScope->enterNamespace($namespace); } $classScope = $classScope->enterClass($declaringClass); - [$templateTypeMap, $phpDocParameterTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $phpDocReturnType, $phpDocThrowType, $deprecatedDescription, $isDeprecated, $isInternal, $isFinal, $isPure, $acceptsNamedArguments, , $phpDocComment, $asserts, $selfOutType, $phpDocParameterOutTypes, , , , $phpDocPureUnlessCallableIsImpureParameters] = $this->nodeScopeResolver->getPhpDocs($classScope, $methodNode); + [$templateTypeMap, $phpDocParameterTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $phpDocReturnType, $phpDocThrowType, $deprecatedDescription, $isDeprecated, $isInternal, $isFinal, $isPure, $acceptsNamedArguments, , $phpDocComment, $asserts, $selfOutType, $phpDocParameterOutTypes, , , , $phpDocPureUnlessCallableIsImpureParameters, $phpDocPureUnlessParameterPassedParameters] = $this->nodeScopeResolver->getPhpDocs($classScope, $methodNode); $methodScope = $classScope->enterClassMethod( $methodNode, $templateTypeMap, @@ -1197,6 +1205,7 @@ private function inferAndCachePropertyTypes( false, null, $phpDocPureUnlessCallableIsImpureParameters, + $phpDocPureUnlessParameterPassedParameters, ); $propertyTypes = []; diff --git a/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php b/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php index cecedced1d7..8aa5f0f7978 100644 --- a/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php +++ b/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php @@ -50,6 +50,7 @@ class PhpFunctionFromParserNodeReflection implements FunctionReflection, Extende * @param array $phpDocClosureThisTypeParameters * @param list $attributes * @param array $pureUnlessCallableIsImpureParameters + * @param array $pureUnlessParameterPassedParameters */ public function __construct( FunctionLike $functionLike, @@ -74,6 +75,7 @@ public function __construct( private array $phpDocClosureThisTypeParameters, private array $attributes, private array $pureUnlessCallableIsImpureParameters, + private array $pureUnlessParameterPassedParameters, ) { $this->functionLike = $functionLike; @@ -181,6 +183,7 @@ public function getParameters(): array } $pureUnlessCallableIsImpureParameter = TrinaryLogic::createFromBoolean($this->pureUnlessCallableIsImpureParameters[$parameter->var->name] ?? false); + $pureUnlessParameterPassedParameter = TrinaryLogic::createFromBoolean($this->pureUnlessParameterPassedParameters[$parameter->var->name] ?? false); $parameters[] = new PhpParameterFromParserNodeReflection( $parameter->var->name, @@ -197,6 +200,7 @@ public function getParameters(): array $closureThisType, $this->parameterAttributes[$parameter->var->name] ?? [], $pureUnlessCallableIsImpureParameter, + $pureUnlessParameterPassedParameter, ); } diff --git a/src/Reflection/Php/PhpFunctionReflection.php b/src/Reflection/Php/PhpFunctionReflection.php index 15afca5bd98..f1fb979dd45 100644 --- a/src/Reflection/Php/PhpFunctionReflection.php +++ b/src/Reflection/Php/PhpFunctionReflection.php @@ -41,6 +41,7 @@ final class PhpFunctionReflection implements FunctionReflection * @param array $phpDocParameterClosureThisTypes * @param list $attributes * @param array $phpDocParameterPureUnlessCallableIsImpure + * @param array $phpDocParameterPureUnlessParameterPassed */ public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, @@ -64,6 +65,7 @@ public function __construct( private array $phpDocParameterClosureThisTypes, private array $attributes, private array $phpDocParameterPureUnlessCallableIsImpure, + private array $phpDocParameterPureUnlessParameterPassed, ) { } @@ -133,6 +135,7 @@ private function getParameters(): array $this->attributeReflectionFactory->fromNativeReflection($reflection->getAttributes(), InitializerExprContext::fromReflectionParameter($reflection)), $this->allowedConstantsMapProvider->getForFunctionParameter(strtolower($this->reflection->getName()), $reflection->getName()), TrinaryLogic::createFromBoolean($this->phpDocParameterPureUnlessCallableIsImpure[$reflection->getName()] ?? false), + TrinaryLogic::createFromBoolean($this->phpDocParameterPureUnlessParameterPassed[$reflection->getName()] ?? false), ); }, $this->reflection->getParameters()); } diff --git a/src/Reflection/Php/PhpMethodFromParserNodeReflection.php b/src/Reflection/Php/PhpMethodFromParserNodeReflection.php index 290e0cb4b76..60763d4e01b 100644 --- a/src/Reflection/Php/PhpMethodFromParserNodeReflection.php +++ b/src/Reflection/Php/PhpMethodFromParserNodeReflection.php @@ -73,6 +73,7 @@ public function __construct( private bool $isConstructor, array $attributes, array $pureUnlessCallableIsImpureParameters, + array $pureUnlessParameterPassedParameters, ) { if ($this->classMethod instanceof Node\PropertyHook) { @@ -140,6 +141,7 @@ public function __construct( $phpDocClosureThisTypeParameters, $attributes, $pureUnlessCallableIsImpureParameters, + $pureUnlessParameterPassedParameters, ); } diff --git a/src/Reflection/Php/PhpMethodReflection.php b/src/Reflection/Php/PhpMethodReflection.php index e75049d69ef..8ae2f887fca 100644 --- a/src/Reflection/Php/PhpMethodReflection.php +++ b/src/Reflection/Php/PhpMethodReflection.php @@ -64,6 +64,7 @@ final class PhpMethodReflection implements ExtendedMethodReflection * @param array $phpDocClosureThisTypeParameters * @param list $attributes * @param array $pureUnlessCallableIsImpureParameters + * @param array $pureUnlessParameterPassedParameters */ public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, @@ -92,6 +93,7 @@ public function __construct( private array $phpDocClosureThisTypeParameters, private array $attributes, private array $pureUnlessCallableIsImpureParameters, + private array $pureUnlessParameterPassedParameters, ) { } @@ -232,6 +234,7 @@ private function getParameters(): array $this->attributeReflectionFactory->fromNativeReflection($reflection->getAttributes(), InitializerExprContext::fromReflectionParameter($reflection)), $this->allowedConstantsMapProvider->getForMethodParameter($this->declaringClass->getName(), $this->reflection->getName(), $reflection->getName()), TrinaryLogic::createFromBoolean($this->pureUnlessCallableIsImpureParameters[$reflection->getName()] ?? false), + TrinaryLogic::createFromBoolean($this->pureUnlessParameterPassedParameters[$reflection->getName()] ?? false), ), $this->reflection->getParameters()); } @@ -445,6 +448,7 @@ public function changePropertyGetHookPhpDocType(Type $phpDocType): self $this->phpDocClosureThisTypeParameters, $this->attributes, $this->pureUnlessCallableIsImpureParameters, + $this->pureUnlessParameterPassedParameters, ); } @@ -480,6 +484,7 @@ public function changePropertySetHookPhpDocType(string $parameterName, Type $php $this->phpDocClosureThisTypeParameters, $this->attributes, $this->pureUnlessCallableIsImpureParameters, + $this->pureUnlessParameterPassedParameters, ); } diff --git a/src/Reflection/Php/PhpMethodReflectionFactory.php b/src/Reflection/Php/PhpMethodReflectionFactory.php index c2978529d86..583d2cb4d28 100644 --- a/src/Reflection/Php/PhpMethodReflectionFactory.php +++ b/src/Reflection/Php/PhpMethodReflectionFactory.php @@ -21,6 +21,7 @@ interface PhpMethodReflectionFactory * @param array $phpDocClosureThisTypeParameters * @param list $attributes * @param array $pureUnlessCallableIsImpureParameters + * @param array $pureUnlessParameterPassedParameters */ public function create( ClassReflection $declaringClass, @@ -45,6 +46,7 @@ public function create( bool $acceptsNamedArguments, array $attributes, array $pureUnlessCallableIsImpureParameters, + array $pureUnlessParameterPassedParameters, ): PhpMethodReflection; } diff --git a/src/Reflection/Php/PhpParameterFromParserNodeReflection.php b/src/Reflection/Php/PhpParameterFromParserNodeReflection.php index 754f6622c2a..9535b0c7f7c 100644 --- a/src/Reflection/Php/PhpParameterFromParserNodeReflection.php +++ b/src/Reflection/Php/PhpParameterFromParserNodeReflection.php @@ -34,6 +34,7 @@ public function __construct( private ?Type $closureThisType, private array $attributes, private TrinaryLogic $pureUnlessCallableIsImpureParameter, + private TrinaryLogic $pureUnlessParameterPassedParameter, ) { } @@ -131,4 +132,9 @@ public function isPureUnlessCallableIsImpureParameter(): TrinaryLogic return $this->pureUnlessCallableIsImpureParameter; } + public function isPureUnlessParameterPassedParameter(): TrinaryLogic + { + return $this->pureUnlessParameterPassedParameter; + } + } diff --git a/src/Reflection/Php/PhpParameterReflection.php b/src/Reflection/Php/PhpParameterReflection.php index 75365f543ef..afc2b48d8db 100644 --- a/src/Reflection/Php/PhpParameterReflection.php +++ b/src/Reflection/Php/PhpParameterReflection.php @@ -38,6 +38,7 @@ public function __construct( private array $attributes, private ?ParameterAllowedConstants $allowedConstants, private TrinaryLogic $pureUnlessCallableIsImpureParameter, + private TrinaryLogic $pureUnlessParameterPassedParameter, ) { } @@ -166,4 +167,9 @@ public function isPureUnlessCallableIsImpureParameter(): TrinaryLogic return $this->pureUnlessCallableIsImpureParameter; } + public function isPureUnlessParameterPassedParameter(): TrinaryLogic + { + return $this->pureUnlessParameterPassedParameter; + } + } diff --git a/src/Reflection/ResolvedFunctionVariantWithOriginal.php b/src/Reflection/ResolvedFunctionVariantWithOriginal.php index 0151110214b..cf8cd6bf4a8 100644 --- a/src/Reflection/ResolvedFunctionVariantWithOriginal.php +++ b/src/Reflection/ResolvedFunctionVariantWithOriginal.php @@ -123,6 +123,7 @@ function (ExtendedParameterReflection $param): ExtendedParameterReflection { $param->getAttributes(), $param->getAllowedConstants(), $param->isPureUnlessCallableIsImpureParameter(), + $param->isPureUnlessParameterPassedParameter(), ); }, $this->parametersAcceptor->getParameters(), diff --git a/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php b/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php index 6b40422c1d4..6a868d39971 100644 --- a/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php +++ b/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php @@ -111,11 +111,15 @@ public function findFunctionReflection(string $functionName): ?NativeFunctionRef $allowedConstantsMapProvider = $this->allowedConstantsMapProvider; $pureUnlessCallableIsImpureParameters = []; + $pureUnlessParameterPassedParameters = []; if ($this->signatureMapProvider->hasFunctionMetadata($lowerCasedFunctionName)) { $functionMetadata = $this->signatureMapProvider->getFunctionMetadata($lowerCasedFunctionName); if (isset($functionMetadata['pureUnlessCallableIsImpureParameters'])) { $pureUnlessCallableIsImpureParameters = $functionMetadata['pureUnlessCallableIsImpureParameters']; } + if (isset($functionMetadata['pureUnlessParameterPassedParameters'])) { + $pureUnlessParameterPassedParameters = $functionMetadata['pureUnlessParameterPassedParameters']; + } } else { $functionMetadata = null; } @@ -126,7 +130,7 @@ public function findFunctionReflection(string $functionName): ?NativeFunctionRef $variantsByType[$signatureType][] = new ExtendedFunctionVariant( TemplateTypeMap::createEmpty(), null, - array_map(static function (ParameterSignature $parameterSignature) use ($phpDoc, $lowerCasedFunctionName, $allowedConstantsMapProvider, $pureUnlessCallableIsImpureParameters): ExtendedNativeParameterReflection { + array_map(static function (ParameterSignature $parameterSignature) use ($phpDoc, $lowerCasedFunctionName, $allowedConstantsMapProvider, $pureUnlessCallableIsImpureParameters, $pureUnlessParameterPassedParameters): ExtendedNativeParameterReflection { $name = $parameterSignature->getName(); $type = $parameterSignature->getType(); @@ -134,6 +138,7 @@ public function findFunctionReflection(string $functionName): ?NativeFunctionRef $immediatelyInvokedCallable = TrinaryLogic::createMaybe(); $closureThisType = null; $pureUnlessCallableIsImpureParameter = TrinaryLogic::createFromBoolean($pureUnlessCallableIsImpureParameters[$name] ?? false); + $pureUnlessParameterPassedParameter = TrinaryLogic::createFromBoolean($pureUnlessParameterPassedParameters[$name] ?? false); if ($phpDoc !== null) { if (array_key_exists($parameterSignature->getName(), $phpDoc->getParamTags())) { $phpDocType = $phpDoc->getParamTags()[$parameterSignature->getName()]->getType(); @@ -147,6 +152,9 @@ public function findFunctionReflection(string $functionName): ?NativeFunctionRef if (($phpDoc->getParamsPureUnlessCallableIsImpure()[$parameterSignature->getName()] ?? false) === true) { $pureUnlessCallableIsImpureParameter = TrinaryLogic::createYes(); } + if (($phpDoc->getParamsPureUnlessParameterPassed()[$parameterSignature->getName()] ?? false) === true) { + $pureUnlessParameterPassedParameter = TrinaryLogic::createYes(); + } } return new ExtendedNativeParameterReflection( @@ -164,6 +172,7 @@ public function findFunctionReflection(string $functionName): ?NativeFunctionRef [], $allowedConstantsMapProvider->getForFunctionParameter($lowerCasedFunctionName, $parameterSignature->getName()), $pureUnlessCallableIsImpureParameter, + $pureUnlessParameterPassedParameter, ); }, $functionSignature->getParameters()), $functionSignature->isVariadic(), diff --git a/src/Reflection/SignatureMap/SignatureMapProvider.php b/src/Reflection/SignatureMap/SignatureMapProvider.php index 8afcf9b1b1a..2ccb642ccee 100644 --- a/src/Reflection/SignatureMap/SignatureMapProvider.php +++ b/src/Reflection/SignatureMap/SignatureMapProvider.php @@ -26,12 +26,12 @@ public function hasMethodMetadata(string $className, string $methodName): bool; public function hasFunctionMetadata(string $name): bool; /** - * @return array{hasSideEffects?: bool, pureUnlessCallableIsImpureParameters?: array} + * @return array{hasSideEffects?: bool, pureUnlessCallableIsImpureParameters?: array, pureUnlessParameterPassedParameters?: array} */ public function getMethodMetadata(string $className, string $methodName): array; /** - * @return array{hasSideEffects?: bool, pureUnlessCallableIsImpureParameters?: array} + * @return array{hasSideEffects?: bool, pureUnlessCallableIsImpureParameters?: array, pureUnlessParameterPassedParameters?: array} */ public function getFunctionMetadata(string $functionName): array; diff --git a/src/Reflection/Type/CallbackUnresolvedMethodPrototypeReflection.php b/src/Reflection/Type/CallbackUnresolvedMethodPrototypeReflection.php index 72b5def04fe..9c5812b423e 100644 --- a/src/Reflection/Type/CallbackUnresolvedMethodPrototypeReflection.php +++ b/src/Reflection/Type/CallbackUnresolvedMethodPrototypeReflection.php @@ -120,6 +120,7 @@ function (ExtendedParameterReflection $parameter): ExtendedParameterReflection { $parameter->getAttributes(), $parameter->getAllowedConstants(), $parameter->isPureUnlessCallableIsImpureParameter(), + $parameter->isPureUnlessParameterPassedParameter(), ); }, $acceptor->getParameters(), diff --git a/src/Reflection/Type/CalledOnTypeUnresolvedMethodPrototypeReflection.php b/src/Reflection/Type/CalledOnTypeUnresolvedMethodPrototypeReflection.php index 8e6099d8886..6b2817dd063 100644 --- a/src/Reflection/Type/CalledOnTypeUnresolvedMethodPrototypeReflection.php +++ b/src/Reflection/Type/CalledOnTypeUnresolvedMethodPrototypeReflection.php @@ -107,6 +107,7 @@ private function transformMethodWithStaticType(ClassReflection $declaringClass, $parameter->getAttributes(), $parameter->getAllowedConstants(), $parameter->isPureUnlessCallableIsImpureParameter(), + $parameter->isPureUnlessParameterPassedParameter(), ), $acceptor->getParameters(), ), diff --git a/src/Reflection/WrappedExtendedMethodReflection.php b/src/Reflection/WrappedExtendedMethodReflection.php index 79874e29186..7cdf0437bba 100644 --- a/src/Reflection/WrappedExtendedMethodReflection.php +++ b/src/Reflection/WrappedExtendedMethodReflection.php @@ -79,6 +79,7 @@ public function getVariants(): array [], null, TrinaryLogic::createNo(), + TrinaryLogic::createNo(), ), $variant->getParameters()), $variant->isVariadic(), $variant->getReturnType(), diff --git a/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php b/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php index fcd5702fafe..109d19efb81 100644 --- a/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php +++ b/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php @@ -64,6 +64,9 @@ final class InvalidPHPStanDocTagRule implements Rule '@phpstan-param-immediately-invoked-callable', '@phpstan-param-later-invoked-callable', '@phpstan-param-closure-this', + // TODO: drop this once phpstan/phpdoc-parser#259 is merged and the parser + // recognizes the tag natively (like @phpstan-pure-unless-callable-is-impure). + '@phpstan-pure-unless-parameter-passed', '@phpstan-all-methods-pure', '@phpstan-all-methods-impure', ]; diff --git a/tests/PHPStan/Reflection/ParametersAcceptorSelectorTest.php b/tests/PHPStan/Reflection/ParametersAcceptorSelectorTest.php index 91a7c18d8c2..efd36e19a0a 100644 --- a/tests/PHPStan/Reflection/ParametersAcceptorSelectorTest.php +++ b/tests/PHPStan/Reflection/ParametersAcceptorSelectorTest.php @@ -94,6 +94,7 @@ public static function dataSelectFromTypes(): Generator $parameter->getAttributes(), $parameter->getAllowedConstants(), $parameter->isPureUnlessCallableIsImpureParameter(), + $parameter->isPureUnlessParameterPassedParameter(), ), $datePeriodConstructorVariants[0]->getParameters()), false, new VoidType(), @@ -127,6 +128,7 @@ public static function dataSelectFromTypes(): Generator $parameter->getAttributes(), $parameter->getAllowedConstants(), $parameter->isPureUnlessCallableIsImpureParameter(), + $parameter->isPureUnlessParameterPassedParameter(), ), $datePeriodConstructorVariants[1]->getParameters()), false, new VoidType(), diff --git a/tests/PHPStan/Reflection/SignatureMap/FunctionMetadataTest.php b/tests/PHPStan/Reflection/SignatureMap/FunctionMetadataTest.php index 365e3d49757..789c1a387cd 100644 --- a/tests/PHPStan/Reflection/SignatureMap/FunctionMetadataTest.php +++ b/tests/PHPStan/Reflection/SignatureMap/FunctionMetadataTest.php @@ -20,6 +20,7 @@ public function testSchema(): void Expect::structure([ 'hasSideEffects' => Expect::bool(), 'pureUnlessCallableIsImpureParameters' => Expect::arrayOf(Expect::bool(), Expect::string()), + 'pureUnlessParameterPassedParameters' => Expect::arrayOf(Expect::bool(), Expect::string()), ]) ->assert(static fn ($v) => count((array) $v) > 0, 'Metadata entries must not be empty.') ->required(), diff --git a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php index 1a4d3c3fbba..1708cb67e2d 100644 --- a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php +++ b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php @@ -378,4 +378,73 @@ public function testPureUnlessCallableIsImpurePhp84(): void ]); } + #[RequiresPhp('>= 8.1.0')] + public function testPureUnlessParameterPassed(): void + { + $this->analyse([__DIR__ . '/data/pure-unless-parameter-passed.php'], [ + [ + 'Impure call to function PureUnlessParameterPassedFunction\myReplace() in pure function PureUnlessParameterPassedFunction\purePassingByRef().', + 44, + ], + [ + 'Impure call to function PureUnlessParameterPassedFunction\myReplacePhpstanAlias() in pure function PureUnlessParameterPassedFunction\purePassingByRefAlias().', + 62, + ], + [ + 'Possibly impure call to function PureUnlessParameterPassedFunction\myReplace() in pure function PureUnlessParameterPassedFunction\pureUnpackingArgs().', + 72, + ], + [ + 'Impure call to function PureUnlessParameterPassedFunction\myReplace() in pure function PureUnlessParameterPassedFunction\pureNamedArgForFlaggedParameter().', + 91, + ], + [ + 'Impure instantiation of class PureUnlessParameterPassedFunction\MyReplacerConstructor in pure function PureUnlessParameterPassedFunction\pureConstructorPassingByRef().', + 127, + ], + [ + 'Possibly impure call to method PureUnlessParameterPassedFunction\PureUnlessParameterPassedA::m() in pure function PureUnlessParameterPassedFunction\pureUnionMethodPassingCount().', + 167, + ], + [ + 'Impure call to method PureUnlessParameterPassedFunction\PureUnlessParameterPassedIntersectionA::m() in pure function PureUnlessParameterPassedFunction\pureIntersectionMethodPassingCount().', + 206, + ], + [ + 'Impure call to method PureUnlessParameterPassedFunction\Replacer::replace() in pure function PureUnlessParameterPassedFunction\pureCallingMethodPassingByRef().', + 241, + ], + [ + 'Impure call to method PureUnlessParameterPassedFunction\InheritedReplacerChild::replace() in pure function PureUnlessParameterPassedFunction\pureCallingInheritedMethodPassingByRef().', + 297, + ], + [ + 'Impure call to method PureUnlessParameterPassedFunction\InheritedReplacerRenamedChild::replace() in pure function PureUnlessParameterPassedFunction\pureCallingRenamedInheritedMethodPassingByRef().', + 318, + ], + [ + 'Possibly impure call to function PureUnlessParameterPassedFunction\myReplace() in pure function PureUnlessParameterPassedFunction\pureCallingFirstClassCallableOmittingCount().', + 331, + ], + [ + 'Possibly impure call to function PureUnlessParameterPassedFunction\myReplace() in pure function PureUnlessParameterPassedFunction\pureCallingFirstClassCallablePassingCount().', + 343, + ], + ]); + } + + public function testPureUnlessParameterPassedBuiltin(): void + { + $this->analyse([__DIR__ . '/data/pure-unless-parameter-passed-builtin.php'], [ + [ + 'Impure call to function str_replace() in pure function PureUnlessParameterPassedBuiltin\pureStrReplaceWithCount().', + 22, + ], + [ + 'Impure call to function preg_match() in pure function PureUnlessParameterPassedBuiltin\purePregMatchWithMatches().', + 40, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Pure/data/pure-unless-parameter-passed-builtin.php b/tests/PHPStan/Rules/Pure/data/pure-unless-parameter-passed-builtin.php new file mode 100644 index 00000000000..e53b989e951 --- /dev/null +++ b/tests/PHPStan/Rules/Pure/data/pure-unless-parameter-passed-builtin.php @@ -0,0 +1,41 @@ += 8.1 + +namespace PureUnlessParameterPassedFunction; + +/** + * @param-out int $count + * @pure-unless-parameter-passed $count + */ +function myReplace(string $subject, int &$count = 0): string +{ + $count = 1; + + return $subject; +} + +/** + * @param-out int $count + * @phpstan-pure-unless-parameter-passed $count + */ +function myReplacePhpstanAlias(string $subject, int &$count = 0): string +{ + $count = 1; + + return $subject; +} + +/** + * @phpstan-pure + */ +function pureNotPassingByRef(string $s): string +{ + // $count is omitted, so myReplace() stays pure. + return myReplace($s); +} + +/** + * @phpstan-pure + */ +function purePassingByRef(string $s): string +{ + // $count is passed, so myReplace() is impure (the flag is certain). + $count = 0; + + return myReplace($s, $count); +} + +/** + * @phpstan-pure + */ +function pureNotPassingByRefAlias(string $s): string +{ + return myReplacePhpstanAlias($s); +} + +/** + * @phpstan-pure + */ +function purePassingByRefAlias(string $s): string +{ + $count = 0; + + return myReplacePhpstanAlias($s, $count); +} + +/** + * @phpstan-pure + */ +function pureUnpackingArgs(string $s): string +{ + $args = [$s]; + // An unpacked argument list might supply $count, so this stays possibly impure. + return myReplace(...$args); +} + +/** + * @phpstan-pure + */ +function pureNamedArgForOtherParameter(string $s): string +{ + // The named argument targets $subject, not the flagged $count, so this stays pure. + return myReplace(subject: $s); +} + +/** + * @phpstan-pure + */ +function pureNamedArgForFlaggedParameter(string $s): string +{ + $count = 0; + // The named argument explicitly targets the flagged $count parameter. + return myReplace(subject: $s, count: $count); +} + +class MyReplacerConstructor +{ + + public string $s; + + /** + * @param-out int $count + * @pure-unless-parameter-passed $count + */ + public function __construct(string $s, int &$count = 0) + { + $this->s = $s; + $count = 1; + } + +} + +/** + * @phpstan-pure + */ +function pureConstructorNotPassingByRef(string $s): MyReplacerConstructor +{ + // $count is omitted, so instantiation stays pure. + return new MyReplacerConstructor($s); +} + +/** + * @phpstan-pure + */ +function pureConstructorPassingByRef(string $s): MyReplacerConstructor +{ + $count = 0; + // $count is passed, so instantiation is impure (the flag is certain). + return new MyReplacerConstructor($s, $count); +} + +interface PureUnlessParameterPassedA +{ + + /** + * @param-out int $count + * @pure-unless-parameter-passed $count + */ + public function m(string $s, int &$count = 0): string; + +} + +interface PureUnlessParameterPassedB +{ + + public function m(string $s, int &$count = 0): string; + +} + +/** + * @param PureUnlessParameterPassedA|PureUnlessParameterPassedB $obj + * @phpstan-pure + */ +function pureUnionMethodOmittingCount($obj, string $s): string +{ + // The flag is Yes in A and absent (No) in B, so combineAcceptors() merges it to Maybe. + // $count is omitted here, so the call stays pure regardless of the flag's certainty. + return $obj->m($s); +} + +/** + * @param PureUnlessParameterPassedA|PureUnlessParameterPassedB $obj + * @phpstan-pure + */ +function pureUnionMethodPassingCount($obj, string $s): string +{ + $count = 0; + // $count is passed against the Maybe-flagged parameter, so this is possibly impure. + return $obj->m($s, $count); +} + +interface PureUnlessParameterPassedIntersectionA +{ + + /** + * @param-out int $count + * @pure-unless-parameter-passed $count + */ + public function m(string $s, int &$count = 0): string; + +} + +interface PureUnlessParameterPassedIntersectionB +{ + + public function n(): string; + +} + +/** + * @param PureUnlessParameterPassedIntersectionA&PureUnlessParameterPassedIntersectionB $obj + * @phpstan-pure + */ +function pureIntersectionMethodOmittingCount($obj, string $s): string +{ + // $count is omitted here, so the call stays pure. + return $obj->m($s); +} + +/** + * @param PureUnlessParameterPassedIntersectionA&PureUnlessParameterPassedIntersectionB $obj + * @phpstan-pure + */ +function pureIntersectionMethodPassingCount($obj, string $s): string +{ + $count = 0; + // $count is passed, so this is impure (the flag is certain). + return $obj->m($s, $count); +} + +class Replacer +{ + + /** + * @param-out int $count + * @pure-unless-parameter-passed $count + */ + public function replace(string $subject, int &$count = 0): string + { + $count = 1; + + return $subject; + } + +} + +/** + * @phpstan-pure + */ +function pureCallingMethodNotPassingByRef(Replacer $replacer, string $s): string +{ + // $count is omitted, so the call stays pure. + return $replacer->replace($s); +} + +/** + * @phpstan-pure + */ +function pureCallingMethodPassingByRef(Replacer $replacer, string $s): string +{ + $count = 0; + // $count is passed, so the call is impure (the flag is certain). + return $replacer->replace($s, $count); +} + +abstract class InheritedReplacerParent +{ + + /** + * @param-out int $count + * @pure-unless-parameter-passed $count + */ + abstract public function replace(string $subject, int &$count = 0): string; + +} + +class InheritedReplacerChild extends InheritedReplacerParent +{ + + public function replace(string $subject, int &$count = 0): string + { + $count = 1; + + return $subject; + } + +} + +class InheritedReplacerRenamedChild extends InheritedReplacerParent +{ + + public function replace(string $subject, int &$cnt = 0): string + { + $cnt = 1; + + return $subject; + } + +} + +/** + * @phpstan-pure + */ +function pureCallingInheritedMethodNotPassingByRef(InheritedReplacerChild $replacer, string $s): string +{ + // The child does not re-declare @pure-unless-parameter-passed; it is inherited + // from the parent. $count is omitted here, so the call stays pure. + return $replacer->replace($s); +} + +/** + * @phpstan-pure + */ +function pureCallingInheritedMethodPassingByRef(InheritedReplacerChild $replacer, string $s): string +{ + $count = 0; + // The inherited @pure-unless-parameter-passed applies to the child, so passing + // $count makes the call impure (the flag is certain). + return $replacer->replace($s, $count); +} + +/** + * @phpstan-pure + */ +function pureCallingRenamedInheritedMethodNotPassingByRef(InheritedReplacerRenamedChild $replacer, string $s): string +{ + // The parent flags $count; the child renames it to $cnt. The inherited flag + // still applies to the renamed parameter. It is omitted here, so this stays pure. + return $replacer->replace($s); +} + +/** + * @phpstan-pure + */ +function pureCallingRenamedInheritedMethodPassingByRef(InheritedReplacerRenamedChild $replacer, string $s): string +{ + $cnt = 0; + // The inherited flag applies to the renamed parameter, so passing it makes + // the call impure (the flag is certain). + return $replacer->replace($s, $cnt); +} + +/** + * @phpstan-pure + */ +function pureCallingFirstClassCallableOmittingCount(string $s): string +{ + $f = myReplace(...); + // A first-class callable's purity is evaluated from its ParametersAcceptor + // alone, without scope/args, so @pure-unless-parameter-passed cannot gate on + // whether $count is actually passed at this call site; it stays possibly + // impure even though $count is omitted here. + return $f($s); +} + +/** + * @phpstan-pure + */ +function pureCallingFirstClassCallablePassingCount(string $s): string +{ + $count = 0; + $f = myReplace(...); + // The first-class callable is called with the flagged $count passed, so this + // stays possibly impure. + return $f($s, $count); +}