diff --git a/bin/functionMetadata_original.php b/bin/functionMetadata_original.php index 78ac65db296..8f4eb456e53 100644 --- a/bin/functionMetadata_original.php +++ b/bin/functionMetadata_original.php @@ -1,5 +1,21 @@ bool] + * false: the call is pure. true: the call has side effects. + * - ['pureUnlessCallableIsImpureParameters' => array] + * 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. + */ + +/** @var array}> */ return [ 'abs' => ['hasSideEffects' => false], 'acos' => ['hasSideEffects' => false], @@ -20,6 +36,8 @@ 'apcu_key_info' => ['hasSideEffects' => true], 'apcu_sma_info' => ['hasSideEffects' => true], 'apcu_store' => ['hasSideEffects' => true], + 'array_all' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], + 'array_any' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], 'array_change_key_case' => ['hasSideEffects' => false], 'array_chunk' => ['hasSideEffects' => false], 'array_column' => ['hasSideEffects' => false], @@ -28,20 +46,24 @@ 'array_diff' => ['hasSideEffects' => false], 'array_diff_assoc' => ['hasSideEffects' => false], 'array_diff_key' => ['hasSideEffects' => false], - 'array_diff_uassoc' => ['hasSideEffects' => false], - 'array_diff_ukey' => ['hasSideEffects' => false], + 'array_diff_uassoc' => ['pureUnlessCallableIsImpureParameters' => ['key_compare_func' => true]], + 'array_diff_ukey' => ['pureUnlessCallableIsImpureParameters' => ['key_comp_func' => true]], 'array_fill' => ['hasSideEffects' => false], 'array_fill_keys' => ['hasSideEffects' => false], + 'array_filter' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], + 'array_find' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], + 'array_find_key' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], 'array_flip' => ['hasSideEffects' => false], 'array_intersect' => ['hasSideEffects' => false], 'array_intersect_assoc' => ['hasSideEffects' => false], 'array_intersect_key' => ['hasSideEffects' => false], - 'array_intersect_uassoc' => ['hasSideEffects' => false], - 'array_intersect_ukey' => ['hasSideEffects' => false], + 'array_intersect_uassoc' => ['pureUnlessCallableIsImpureParameters' => ['key_compare_func' => true]], + 'array_intersect_ukey' => ['pureUnlessCallableIsImpureParameters' => ['key_compare_func' => true]], 'array_key_first' => ['hasSideEffects' => false], 'array_key_last' => ['hasSideEffects' => false], 'array_key_exists' => ['hasSideEffects' => false], 'array_keys' => ['hasSideEffects' => false], + 'array_map' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], 'array_merge' => ['hasSideEffects' => false], 'array_merge_recursive' => ['hasSideEffects' => false], 'array_pad' => ['hasSideEffects' => false], @@ -49,18 +71,19 @@ 'array_product' => ['hasSideEffects' => false], 'array_push' => ['hasSideEffects' => true], 'array_rand' => ['hasSideEffects' => false], + 'array_reduce' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], 'array_replace' => ['hasSideEffects' => false], 'array_replace_recursive' => ['hasSideEffects' => false], 'array_reverse' => ['hasSideEffects' => false], 'array_shift' => ['hasSideEffects' => true], 'array_slice' => ['hasSideEffects' => false], 'array_sum' => ['hasSideEffects' => false], - 'array_udiff' => ['hasSideEffects' => false], - 'array_udiff_assoc' => ['hasSideEffects' => false], - 'array_udiff_uassoc' => ['hasSideEffects' => false], - 'array_uintersect' => ['hasSideEffects' => false], - 'array_uintersect_assoc' => ['hasSideEffects' => false], - 'array_uintersect_uassoc' => ['hasSideEffects' => false], + 'array_udiff' => ['pureUnlessCallableIsImpureParameters' => ['data_comp_func' => true]], + 'array_udiff_assoc' => ['pureUnlessCallableIsImpureParameters' => ['key_comp_func' => true]], + 'array_udiff_uassoc' => ['pureUnlessCallableIsImpureParameters' => ['data_comp_func' => true, 'key_comp_func' => true]], + 'array_uintersect' => ['pureUnlessCallableIsImpureParameters' => ['data_compare_func' => true]], + 'array_uintersect_assoc' => ['pureUnlessCallableIsImpureParameters' => ['data_compare_func' => true]], + 'array_uintersect_uassoc' => ['pureUnlessCallableIsImpureParameters' => ['data_compare_func' => true, 'key_compare_func' => true]], 'array_unique' => ['hasSideEffects' => false], 'array_unshift' => ['hasSideEffects' => true], 'array_values' => ['hasSideEffects' => false], @@ -81,6 +104,8 @@ 'bcround' => ['hasSideEffects' => false], 'bcfloor' => ['hasSideEffects' => false], 'bcceil' => ['hasSideEffects' => false], + 'call_user_func' => ['pureUnlessCallableIsImpureParameters' => ['function' => true]], + 'call_user_func_array' => ['pureUnlessCallableIsImpureParameters' => ['function' => true]], // continue functionMap.php, line 424 'chgrp' => ['hasSideEffects' => true], 'chmod' => ['hasSideEffects' => true], @@ -97,6 +122,8 @@ 'file_put_contents' => ['hasSideEffects' => true], 'flock' => ['hasSideEffects' => true], 'fopen' => ['hasSideEffects' => true], + 'forward_static_call' => ['pureUnlessCallableIsImpureParameters' => ['function' => true]], + 'forward_static_call_array' => ['pureUnlessCallableIsImpureParameters' => ['function' => true]], 'fpassthru' => ['hasSideEffects' => true], 'fputcsv' => ['hasSideEffects' => true], 'fputs' => ['hasSideEffects' => true], @@ -237,6 +264,7 @@ 'output_reset_rewrite_vars' => ['hasSideEffects' => true], 'pclose' => ['hasSideEffects' => true], 'popen' => ['hasSideEffects' => true], + 'preg_replace_callback' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], 'readfile' => ['hasSideEffects' => true], 'rename' => ['hasSideEffects' => true], 'rewind' => ['hasSideEffects' => true], diff --git a/bin/generate-function-metadata.php b/bin/generate-function-metadata.php index d161d374e46..f5ef1c5101c 100755 --- a/bin/generate-function-metadata.php +++ b/bin/generate-function-metadata.php @@ -119,13 +119,21 @@ public function enterNode(Node $node) ); } - /** @var array $metadata */ + /** @var array}> $metadata */ $metadata = require __DIR__ . '/functionMetadata_original.php'; foreach ($visitor->functions as $functionName) { if (array_key_exists($functionName, $metadata)) { - if ($metadata[$functionName]['hasSideEffects']) { + if (isset($metadata[$functionName]['hasSideEffects']) && $metadata[$functionName]['hasSideEffects']) { throw new ShouldNotHappenException($functionName); } + + if (isset($metadata[$functionName]['pureUnlessCallableIsImpureParameters'])) { + $metadata[$functionName] = [ + 'pureUnlessCallableIsImpureParameters' => $metadata[$functionName]['pureUnlessCallableIsImpureParameters'], + ]; + + continue; + } } $metadata[$functionName] = ['hasSideEffects' => false]; } @@ -177,19 +185,47 @@ public function enterNode(Node $node) * 2) Contribute the functions that have 'hasSideEffects' => true as a modification to bin/functionMetadata_original.php. * 3) Contribute the #[Pure] functions without side effects to https://github.com/JetBrains/phpstorm-stubs * 4) Once the PR from 3) is merged, please update the package here and run ./bin/generate-function-metadata.php. + * + * The array is keyed by lowercase function name or "Class::method". Each entry is + * exactly one of: + * - ['hasSideEffects' => bool] - false: pure, true: has side effects. + * - ['pureUnlessCallableIsImpureParameters' => array] - pure unless + * one of the listed callable parameters (keyed by parameter name) receives an + * impure callable, e.g. array_map()'s 'callback'. */ +/** @var array}> */ return [ %s ]; php; $content = ''; + $escape = static fn (mixed $value): string => var_export($value, true); + $encodeHasSideEffects = static fn (array $meta) => [$escape('hasSideEffects'), $escape($meta['hasSideEffects'])]; + $encodePureUnlessCallableIsImpureParameters = static fn (array $meta) => [ + $escape('pureUnlessCallableIsImpureParameters'), + sprintf( + '[%s]', + implode( + ' ,', + array_map( + static fn ($key, $param) => sprintf('%s => %s', $escape($key), $escape($param)), + array_keys($meta['pureUnlessCallableIsImpureParameters']), + $meta['pureUnlessCallableIsImpureParameters'], + ), + ), + ), + ]; + foreach ($metadata as $name => $meta) { $content .= sprintf( "\t%s => [%s => %s],\n", var_export($name, true), - var_export('hasSideEffects', true), - var_export($meta['hasSideEffects'], true), + ...match (true) { + isset($meta['hasSideEffects']) => $encodeHasSideEffects($meta), + isset($meta['pureUnlessCallableIsImpureParameters']) => $encodePureUnlessCallableIsImpureParameters($meta), + default => throw new ShouldNotHappenException($escape($meta)), + }, ); } diff --git a/resources/functionMetadata.php b/resources/functionMetadata.php index 7a5515b967d..af546d10642 100644 --- a/resources/functionMetadata.php +++ b/resources/functionMetadata.php @@ -12,8 +12,16 @@ * 2) Contribute the functions that have 'hasSideEffects' => true as a modification to bin/functionMetadata_original.php. * 3) Contribute the #[Pure] functions without side effects to https://github.com/JetBrains/phpstorm-stubs * 4) Once the PR from 3) is merged, please update the package here and run ./bin/generate-function-metadata.php. + * + * The array is keyed by lowercase function name or "Class::method". Each entry is + * exactly one of: + * - ['hasSideEffects' => bool] - false: pure, true: has side effects. + * - ['pureUnlessCallableIsImpureParameters' => array] - pure unless + * one of the listed callable parameters (keyed by parameter name) receives an + * impure callable, e.g. array_map()'s 'callback'. */ +/** @var array}> */ return [ 'BackedEnum::from' => ['hasSideEffects' => false], 'BackedEnum::tryFrom' => ['hasSideEffects' => false], @@ -725,6 +733,8 @@ 'apcu_key_info' => ['hasSideEffects' => true], 'apcu_sma_info' => ['hasSideEffects' => true], 'apcu_store' => ['hasSideEffects' => true], + 'array_all' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], + 'array_any' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], 'array_change_key_case' => ['hasSideEffects' => false], 'array_chunk' => ['hasSideEffects' => false], 'array_column' => ['hasSideEffects' => false], @@ -733,23 +743,27 @@ 'array_diff' => ['hasSideEffects' => false], 'array_diff_assoc' => ['hasSideEffects' => false], 'array_diff_key' => ['hasSideEffects' => false], - 'array_diff_uassoc' => ['hasSideEffects' => false], - 'array_diff_ukey' => ['hasSideEffects' => false], + 'array_diff_uassoc' => ['pureUnlessCallableIsImpureParameters' => ['key_compare_func' => true]], + 'array_diff_ukey' => ['pureUnlessCallableIsImpureParameters' => ['key_comp_func' => true]], 'array_fill' => ['hasSideEffects' => false], 'array_fill_keys' => ['hasSideEffects' => false], + 'array_filter' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], + 'array_find' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], + 'array_find_key' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], 'array_first' => ['hasSideEffects' => false], 'array_flip' => ['hasSideEffects' => false], 'array_intersect' => ['hasSideEffects' => false], 'array_intersect_assoc' => ['hasSideEffects' => false], 'array_intersect_key' => ['hasSideEffects' => false], - 'array_intersect_uassoc' => ['hasSideEffects' => false], - 'array_intersect_ukey' => ['hasSideEffects' => false], + 'array_intersect_uassoc' => ['pureUnlessCallableIsImpureParameters' => ['key_compare_func' => true]], + 'array_intersect_ukey' => ['pureUnlessCallableIsImpureParameters' => ['key_compare_func' => true]], 'array_is_list' => ['hasSideEffects' => false], 'array_key_exists' => ['hasSideEffects' => false], 'array_key_first' => ['hasSideEffects' => false], 'array_key_last' => ['hasSideEffects' => false], 'array_keys' => ['hasSideEffects' => false], 'array_last' => ['hasSideEffects' => false], + 'array_map' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], 'array_merge' => ['hasSideEffects' => false], 'array_merge_recursive' => ['hasSideEffects' => false], 'array_pad' => ['hasSideEffects' => false], @@ -757,6 +771,7 @@ 'array_product' => ['hasSideEffects' => false], 'array_push' => ['hasSideEffects' => true], 'array_rand' => ['hasSideEffects' => false], + 'array_reduce' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], 'array_replace' => ['hasSideEffects' => false], 'array_replace_recursive' => ['hasSideEffects' => false], 'array_reverse' => ['hasSideEffects' => false], @@ -764,12 +779,12 @@ 'array_shift' => ['hasSideEffects' => true], 'array_slice' => ['hasSideEffects' => false], 'array_sum' => ['hasSideEffects' => false], - 'array_udiff' => ['hasSideEffects' => false], - 'array_udiff_assoc' => ['hasSideEffects' => false], - 'array_udiff_uassoc' => ['hasSideEffects' => false], - 'array_uintersect' => ['hasSideEffects' => false], - 'array_uintersect_assoc' => ['hasSideEffects' => false], - 'array_uintersect_uassoc' => ['hasSideEffects' => false], + 'array_udiff' => ['pureUnlessCallableIsImpureParameters' => ['data_comp_func' => true]], + 'array_udiff_assoc' => ['pureUnlessCallableIsImpureParameters' => ['key_comp_func' => true]], + 'array_udiff_uassoc' => ['pureUnlessCallableIsImpureParameters' => ['data_comp_func' => true ,'key_comp_func' => true]], + 'array_uintersect' => ['pureUnlessCallableIsImpureParameters' => ['data_compare_func' => true]], + 'array_uintersect_assoc' => ['pureUnlessCallableIsImpureParameters' => ['data_compare_func' => true]], + 'array_uintersect_uassoc' => ['pureUnlessCallableIsImpureParameters' => ['data_compare_func' => true ,'key_compare_func' => true]], 'array_unique' => ['hasSideEffects' => false], 'array_unshift' => ['hasSideEffects' => true], 'array_values' => ['hasSideEffects' => false], @@ -803,6 +818,8 @@ 'bzerror' => ['hasSideEffects' => false], 'bzerrstr' => ['hasSideEffects' => false], 'bzopen' => ['hasSideEffects' => false], + 'call_user_func' => ['pureUnlessCallableIsImpureParameters' => ['function' => true]], + 'call_user_func_array' => ['pureUnlessCallableIsImpureParameters' => ['function' => true]], 'ceil' => ['hasSideEffects' => false], 'checkdate' => ['hasSideEffects' => false], 'checkdnsrr' => ['hasSideEffects' => false], @@ -954,6 +971,8 @@ 'fmod' => ['hasSideEffects' => false], 'fnmatch' => ['hasSideEffects' => true], 'fopen' => ['hasSideEffects' => true], + 'forward_static_call' => ['pureUnlessCallableIsImpureParameters' => ['function' => true]], + 'forward_static_call_array' => ['pureUnlessCallableIsImpureParameters' => ['function' => true]], 'fpassthru' => ['hasSideEffects' => true], 'fputcsv' => ['hasSideEffects' => true], 'fputs' => ['hasSideEffects' => true], @@ -1616,6 +1635,7 @@ 'preg_last_error' => ['hasSideEffects' => true], 'preg_last_error_msg' => ['hasSideEffects' => true], 'preg_quote' => ['hasSideEffects' => false], + 'preg_replace_callback' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]], 'preg_split' => ['hasSideEffects' => false], 'property_exists' => ['hasSideEffects' => false], 'quoted_printable_decode' => ['hasSideEffects' => false], @@ -1757,4 +1777,4 @@ 'zlib_encode' => ['hasSideEffects' => false], 'zlib_get_coding_type' => ['hasSideEffects' => false], -]; \ No newline at end of file +]; diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 75dcb6246e0..111fe38a682 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -32,6 +32,7 @@ use PHPStan\DependencyInjection\Type\DynamicThrowTypeExtensionProvider; use PHPStan\Node\MethodReturnStatementsNode; use PHPStan\Parser\NewAssignedToPropertyVisitor; +use PHPStan\Reflection\Callables\SimpleImpurePoint; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\Dummy\DummyConstructorReflection; use PHPStan\Reflection\ExtendedParametersAcceptor; @@ -258,6 +259,13 @@ private function processConstructorReflection(string $className, New_ $expr, Mut if ($constructorReflection !== null) { if (!$constructorReflection->hasSideEffects()->no()) { $certain = $constructorReflection->isPure()->no(); + $verdict = SimpleImpurePoint::resolvePureUnlessCallableIsImpureVerdict($parametersAcceptor, $scope, $expr->getArgs()); + if ($verdict !== null && $verdict->yes()) { + return [$constructorReflection, $classReflection, $parametersAcceptor, $impurePoints]; + } + if ($verdict !== null && $verdict->no()) { + $certain = true; + } $impurePoints[] = new ImpurePoint( $scope, $expr, diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 33e32539ffc..5245e5012e6 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1534,6 +1534,7 @@ public function enterTrait(ClassReflection $traitReflection): self * @param Type[] $parameterOutTypes * @param array $immediatelyInvokedCallableParameters * @param array $phpDocClosureThisTypeParameters + * @param array $phpDocPureUnlessCallableIsImpureParameters */ public function enterClassMethod( Node\Stmt\ClassMethod $classMethod, @@ -1555,6 +1556,7 @@ public function enterClassMethod( array $phpDocClosureThisTypeParameters = [], bool $isConstructor = false, ?ResolvedPhpDocBlock $resolvedPhpDocBlock = null, + array $phpDocPureUnlessCallableIsImpureParameters = [], ): self { if (!$this->isInClass()) { @@ -1590,6 +1592,7 @@ public function enterClassMethod( array_map(fn (Type $type): Type => $this->transformStaticType(TemplateTypeHelper::toArgument($type)), $phpDocClosureThisTypeParameters), $isConstructor, $this->attributeReflectionFactory->fromAttrGroups($classMethod->attrGroups, InitializerExprContext::fromStubParameter($this->getClassReflection()->getName(), $this->getFile(), $classMethod)), + $phpDocPureUnlessCallableIsImpureParameters, ), !$classMethod->isStatic(), ); @@ -1679,6 +1682,7 @@ public function enterPropertyHook( [], false, $this->attributeReflectionFactory->fromAttrGroups($hook->attrGroups, InitializerExprContext::fromStubParameter($this->getClassReflection()->getName(), $this->getFile(), $hook)), + [], ), true, ); @@ -1755,6 +1759,7 @@ private function getParameterAttributes(ClassMethod|Function_|PropertyHook $func * @param Type[] $parameterOutTypes * @param array $immediatelyInvokedCallableParameters * @param array $phpDocClosureThisTypeParameters + * @param array $pureUnlessCallableIsImpureParameters */ public function enterFunction( Node\Stmt\Function_ $function, @@ -1772,6 +1777,7 @@ public function enterFunction( array $parameterOutTypes = [], array $immediatelyInvokedCallableParameters = [], array $phpDocClosureThisTypeParameters = [], + array $pureUnlessCallableIsImpureParameters = [], ): self { return $this->enterFunctionLike( @@ -1797,6 +1803,7 @@ public function enterFunction( $immediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $this->attributeReflectionFactory->fromAttrGroups($function->attrGroups, InitializerExprContext::fromStubParameter(null, $this->getFile(), $function)), + $pureUnlessCallableIsImpureParameters, ), false, ); diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index b2252cdc4eb..e8711770ab1 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -870,7 +870,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] = $this->getPhpDocs($scope, $stmt); + [$templateTypeMap, $phpDocParameterTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $phpDocReturnType, $phpDocThrowType, $deprecatedDescription, $isDeprecated, $isInternal, $isFinal, $isPure, $acceptsNamedArguments, $isReadOnly, $phpDocComment, $asserts, $selfOutType, $phpDocParameterOutTypes, , , , $pureUnlessCallableIsImpureParameters] = $this->getPhpDocs($scope, $stmt); foreach ($stmt->params as $param) { $this->processParamNode($stmt, $param, $scope, $storage, $nodeCallback); @@ -906,6 +906,8 @@ public function processStmtNode( $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $isConstructor, + null, + $pureUnlessCallableIsImpureParameters, ); if (!$scope->isInClass()) { @@ -4919,7 +4921,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} + * @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} */ public function getPhpDocs(Scope $scope, Node\FunctionLike|Node\Stmt\Property $node): array { @@ -4949,6 +4951,7 @@ public function getPhpDocs(Scope $scope, Node\FunctionLike|Node\Stmt\Property $n $resolvedPhpDoc = null; $functionName = null; $phpDocParameterOutTypes = []; + $phpDocPureUnlessCallableIsImpureParameters = []; if ($node instanceof Node\Stmt\ClassMethod) { if (!$scope->isInClass()) { @@ -5082,6 +5085,7 @@ public function getPhpDocs(Scope $scope, Node\FunctionLike|Node\Stmt\Property $n $asserts = Assertions::createFromResolvedPhpDocBlock($resolvedPhpDoc); $selfOutType = $resolvedPhpDoc->getSelfOutTag() !== null ? $resolvedPhpDoc->getSelfOutTag()->getType() : null; $varTags = $resolvedPhpDoc->getVarTags(); + $phpDocPureUnlessCallableIsImpureParameters = $resolvedPhpDoc->getParamsPureUnlessCallableIsImpure(); } if ($acceptsNamedArguments && $scope->isInClass()) { @@ -5105,7 +5109,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]; + return [$templateTypeMap, $phpDocParameterTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $phpDocReturnType, $phpDocThrowType, $deprecatedDescription, $isDeprecated, $isInternal, $isFinal, $isPure, $acceptsNamedArguments, $isReadOnly, $docComment, $asserts, $selfOutType, $phpDocParameterOutTypes, $varTags, $isAllowedPrivateMutation, $resolvedPhpDoc, $phpDocPureUnlessCallableIsImpureParameters]; } private function transformStaticType(ClassReflection $declaringClass, Type $type): Type diff --git a/src/PhpDoc/PhpDocNodeResolver.php b/src/PhpDoc/PhpDocNodeResolver.php index 05167dc6908..2304edc1490 100644 --- a/src/PhpDoc/PhpDocNodeResolver.php +++ b/src/PhpDoc/PhpDocNodeResolver.php @@ -387,6 +387,22 @@ public function resolveParamImmediatelyInvokedCallable(PhpDocNode $phpDocNode): return $parameters; } + /** + * @return array + */ + public function resolveParamPureUnlessCallableIsImpure(PhpDocNode $phpDocNode): array + { + $parameters = []; + foreach (['@pure-unless-callable-is-impure', '@phpstan-pure-unless-callable-is-impure'] as $tagName) { + foreach ($phpDocNode->getPureUnlessCallableIsImpureTagValues($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 be6da4fc46d..c00634c963f 100644 --- a/src/PhpDoc/ResolvedPhpDocBlock.php +++ b/src/PhpDoc/ResolvedPhpDocBlock.php @@ -96,6 +96,9 @@ final class ResolvedPhpDocBlock /** @var array|false */ private array|false $paramsImmediatelyInvokedCallable = false; + /** @var array|false */ + private array|false $paramsPureUnlessCallableIsImpure = false; + /** @var array|false */ private array|false $paramClosureThisTags = false; @@ -220,6 +223,7 @@ public static function createEmpty(): self $self->paramTags = []; $self->paramOutTags = []; $self->paramsImmediatelyInvokedCallable = []; + $self->paramsPureUnlessCallableIsImpure = []; $self->paramClosureThisTags = []; $self->returnTag = null; $self->throwsTag = null; @@ -276,6 +280,7 @@ public function merge(ResolvedPhpDocBlock $parent, InheritedPhpDocParameterMappi $result->paramTags = self::mergeParamTags($this->getParamTags(), $parent, $parameterMapping, $parentClass); $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->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); @@ -584,6 +589,18 @@ public function getParamsImmediatelyInvokedCallable(): array return $this->paramsImmediatelyInvokedCallable; } + /** + * @return array + */ + public function getParamsPureUnlessCallableIsImpure(): array + { + if ($this->paramsPureUnlessCallableIsImpure === false) { + $this->paramsPureUnlessCallableIsImpure = $this->phpDocNodeResolver->resolveParamPureUnlessCallableIsImpure($this->phpDocNode); + } + + return $this->paramsPureUnlessCallableIsImpure; + } + /** * @return array */ @@ -1085,6 +1102,34 @@ private static function mergeOneParentParamImmediatelyInvokedCallable(array $par return $paramsImmediatelyInvokedCallable; } + /** + * @param array $paramsPureUnlessCallableIsImpure + * @return array + */ + private static function mergeParamsPureUnlessCallableIsImpure(array $paramsPureUnlessCallableIsImpure, self $parent, InheritedPhpDocParameterMapping $parameterMapping): array + { + return self::mergeOneParentParamPureUnlessCallableIsImpure($paramsPureUnlessCallableIsImpure, $parent, $parameterMapping); + } + + /** + * @param array $paramsPureUnlessCallableIsImpure + * @return array + */ + private static function mergeOneParentParamPureUnlessCallableIsImpure(array $paramsPureUnlessCallableIsImpure, self $parent, InheritedPhpDocParameterMapping $parameterMapping): array + { + $parentPureUnlessCallableIsImpure = $parameterMapping->transformArrayKeysWithParameterNameMapping($parent->getParamsPureUnlessCallableIsImpure()); + + foreach ($parentPureUnlessCallableIsImpure as $name => $parentIsPureUnlessCallableIsImpure) { + if (array_key_exists($name, $paramsPureUnlessCallableIsImpure)) { + continue; + } + + $paramsPureUnlessCallableIsImpure[$name] = $parentIsPureUnlessCallableIsImpure; + } + + return $paramsPureUnlessCallableIsImpure; + } + /** * @param array $paramsClosureThisTags * @return array diff --git a/src/Reflection/Annotations/AnnotationMethodReflection.php b/src/Reflection/Annotations/AnnotationMethodReflection.php index 2f0b233122b..dbd3f650f40 100644 --- a/src/Reflection/Annotations/AnnotationMethodReflection.php +++ b/src/Reflection/Annotations/AnnotationMethodReflection.php @@ -179,6 +179,11 @@ public function isPure(): TrinaryLogic return TrinaryLogic::createMaybe(); } + public function getPureUnlessCallableIsImpureParameters(): array + { + return []; + } + public function getAttributes(): array { return []; diff --git a/src/Reflection/Annotations/AnnotationsMethodParameterReflection.php b/src/Reflection/Annotations/AnnotationsMethodParameterReflection.php index 93941cf0698..f6d719dcdd3 100644 --- a/src/Reflection/Annotations/AnnotationsMethodParameterReflection.php +++ b/src/Reflection/Annotations/AnnotationsMethodParameterReflection.php @@ -92,4 +92,9 @@ public function checkAllowedConstants(array $constants): AllowedConstantsResult return new AllowedConstantsResult([], [], false); } + public function isPureUnlessCallableIsImpureParameter(): bool + { + return false; + } + } diff --git a/src/Reflection/BetterReflection/BetterReflectionProvider.php b/src/Reflection/BetterReflection/BetterReflectionProvider.php index 49197e64e3b..68bee839746 100644 --- a/src/Reflection/BetterReflection/BetterReflectionProvider.php +++ b/src/Reflection/BetterReflection/BetterReflectionProvider.php @@ -49,6 +49,7 @@ use PHPStan\Reflection\Php\PhpFunctionReflection; use PHPStan\Reflection\ReflectionProvider; use PHPStan\Reflection\SignatureMap\NativeFunctionReflectionProvider; +use PHPStan\Reflection\SignatureMap\SignatureMapProvider; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\FileTypeMapper; @@ -92,6 +93,7 @@ public function __construct( private DeprecationProvider $deprecationProvider, private PhpVersion $phpVersion, private NativeFunctionReflectionProvider $nativeFunctionReflectionProvider, + private SignatureMapProvider $signatureMapProvider, private StubPhpDocProvider $stubPhpDocProvider, private FunctionReflectionFactory $functionReflectionFactory, private RelativePathHelper $relativePathHelper, @@ -292,6 +294,7 @@ private function getCustomFunction(string $functionName): PhpFunctionReflection $phpDocParameterOutTags = []; $phpDocParameterImmediatelyInvokedCallable = []; $phpDocParameterClosureThisTypeTags = []; + $phpDocParameterPureUnlessCallableIsImpure = []; $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) { @@ -318,6 +321,19 @@ private function getCustomFunction(string $functionName): PhpFunctionReflection $phpDocParameterOutTags = $resolvedPhpDoc->getParamOutTags(); $phpDocParameterImmediatelyInvokedCallable = $resolvedPhpDoc->getParamsImmediatelyInvokedCallable(); $phpDocParameterClosureThisTypeTags = $resolvedPhpDoc->getParamClosureThisTags(); + $phpDocParameterPureUnlessCallableIsImpure = $resolvedPhpDoc->getParamsPureUnlessCallableIsImpure(); + } + + $lowerCasedFunctionName = strtolower($reflectionFunction->getName()); + if ($this->signatureMapProvider->hasFunctionMetadata($lowerCasedFunctionName)) { + $functionMetadata = $this->signatureMapProvider->getFunctionMetadata($lowerCasedFunctionName); + foreach ($functionMetadata['pureUnlessCallableIsImpureParameters'] ?? [] as $parameterName => $isPureUnlessCallableIsImpure) { + if (($phpDocParameterPureUnlessCallableIsImpure[$parameterName] ?? false) === true) { + continue; + } + + $phpDocParameterPureUnlessCallableIsImpure[$parameterName] = $isPureUnlessCallableIsImpure; + } } return $this->functionReflectionFactory->create( @@ -338,6 +354,7 @@ private function getCustomFunction(string $functionName): PhpFunctionReflection $phpDocParameterImmediatelyInvokedCallable, 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, ); } diff --git a/src/Reflection/Callables/SimpleImpurePoint.php b/src/Reflection/Callables/SimpleImpurePoint.php index 6274d75ed12..7f9828e116e 100644 --- a/src/Reflection/Callables/SimpleImpurePoint.php +++ b/src/Reflection/Callables/SimpleImpurePoint.php @@ -6,9 +6,12 @@ use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\Scope; use PHPStan\Reflection\ExtendedMethodReflection; +use PHPStan\Reflection\ExtendedParameterReflection; use PHPStan\Reflection\FunctionReflection; use PHPStan\Reflection\ParametersAcceptor; +use PHPStan\TrinaryLogic; use PHPStan\Type\Type; +use function count; use function sprintf; /** @@ -59,6 +62,18 @@ public static function createFromVariant(FunctionReflection|ExtendedMethodReflec $certain = $certain || $variant->getReturnType()->isVoid()->yes(); } + if (!$certain && $scope !== null && $variant !== null) { + $verdict = self::resolvePureUnlessCallableIsImpureVerdict($variant, $scope, $args); + if ($verdict !== null) { + if ($verdict->yes()) { + return null; + } + if ($verdict->no()) { + $certain = true; + } + } + } + if ($function instanceof FunctionReflection) { if (isset(self::SIDE_EFFECT_FLIP_PARAMETERS[$function->getName()]) && $scope !== null) { [ @@ -116,6 +131,78 @@ public static function createFromVariant(FunctionReflection|ExtendedMethodReflec return null; } + /** + * Combined purity verdict of all arguments passed to parameters flagged + * with @pure-unless-callable-is-impure. Returns null when the variant has + * no such parameters (so the caller keeps its current behavior). Shared with + * NewHandler, which applies it to constructor calls. + * + * @param Arg[] $args + */ + public static function resolvePureUnlessCallableIsImpureVerdict(ParametersAcceptor $variant, Scope $scope, array $args): ?TrinaryLogic + { + $parameters = $variant->getParameters(); + $verdict = null; + + foreach ($parameters as $parameterIndex => $parameter) { + if (!$parameter instanceof ExtendedParameterReflection) { + continue; + } + if (!$parameter->isPureUnlessCallableIsImpureParameter()) { + continue; + } + + $verdict ??= TrinaryLogic::createYes(); + + $matchedArg = null; + $hasNamedParameter = false; + foreach ($args as $i => $arg) { + 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) { + // Optional callback omitted (e.g. array_filter($arr)) - pure. + continue; + } + + $argType = $scope->getType($matchedArg->value); + if ($argType->isNull()->yes()) { + // Explicit null callback (e.g. array_filter($arr, null)) - pure. + continue; + } + + if (!$argType->isCallable()->yes()) { + $verdict = $verdict->and(TrinaryLogic::createMaybe()); + continue; + } + + $acceptors = $argType->getCallableParametersAcceptors($scope); + if (count($acceptors) === 0) { + $verdict = $verdict->and(TrinaryLogic::createMaybe()); + continue; + } + + foreach ($acceptors as $acceptor) { + $verdict = $verdict->and($acceptor->isPure()); + } + } + + return $verdict; + } + /** @return ImpurePointIdentifier */ public function getIdentifier(): string { diff --git a/src/Reflection/Dummy/ChangedTypeMethodReflection.php b/src/Reflection/Dummy/ChangedTypeMethodReflection.php index d525f2661d9..20e540d0858 100644 --- a/src/Reflection/Dummy/ChangedTypeMethodReflection.php +++ b/src/Reflection/Dummy/ChangedTypeMethodReflection.php @@ -168,6 +168,11 @@ public function isPure(): TrinaryLogic return $this->reflection->isPure(); } + public function getPureUnlessCallableIsImpureParameters(): array + { + return $this->reflection->getPureUnlessCallableIsImpureParameters(); + } + public function getAttributes(): array { return $this->reflection->getAttributes(); diff --git a/src/Reflection/Dummy/DummyConstructorReflection.php b/src/Reflection/Dummy/DummyConstructorReflection.php index 844a5340e11..d0e91d5f264 100644 --- a/src/Reflection/Dummy/DummyConstructorReflection.php +++ b/src/Reflection/Dummy/DummyConstructorReflection.php @@ -153,6 +153,11 @@ public function isPure(): TrinaryLogic return TrinaryLogic::createYes(); } + public function getPureUnlessCallableIsImpureParameters(): array + { + return []; + } + public function getAttributes(): array { return []; diff --git a/src/Reflection/Dummy/DummyMethodReflection.php b/src/Reflection/Dummy/DummyMethodReflection.php index afe694a7c56..a47fb22d055 100644 --- a/src/Reflection/Dummy/DummyMethodReflection.php +++ b/src/Reflection/Dummy/DummyMethodReflection.php @@ -145,6 +145,11 @@ public function isPure(): TrinaryLogic return TrinaryLogic::createMaybe(); } + public function getPureUnlessCallableIsImpureParameters(): array + { + return []; + } + public function getAttributes(): array { return []; diff --git a/src/Reflection/ExtendedMethodReflection.php b/src/Reflection/ExtendedMethodReflection.php index b9cf6acddf7..56e100b26f3 100644 --- a/src/Reflection/ExtendedMethodReflection.php +++ b/src/Reflection/ExtendedMethodReflection.php @@ -69,6 +69,11 @@ public function isBuiltin(): TrinaryLogic|bool; */ public function isPure(): TrinaryLogic; + /** + * @return array + */ + public function getPureUnlessCallableIsImpureParameters(): array; + /** @return list */ public function getAttributes(): array; diff --git a/src/Reflection/ExtendedParameterReflection.php b/src/Reflection/ExtendedParameterReflection.php index 1ccd1d4b935..07ef8f243e8 100644 --- a/src/Reflection/ExtendedParameterReflection.php +++ b/src/Reflection/ExtendedParameterReflection.php @@ -36,4 +36,6 @@ public function getAllowedConstants(): ?ParameterAllowedConstants; */ public function checkAllowedConstants(array $constants): AllowedConstantsResult; + public function isPureUnlessCallableIsImpureParameter(): bool; + } diff --git a/src/Reflection/FunctionReflection.php b/src/Reflection/FunctionReflection.php index d719fc62e58..4286ac7320a 100644 --- a/src/Reflection/FunctionReflection.php +++ b/src/Reflection/FunctionReflection.php @@ -68,6 +68,11 @@ public function returnsByReference(): TrinaryLogic; */ public function isPure(): TrinaryLogic; + /** + * @return array + */ + public function getPureUnlessCallableIsImpureParameters(): array; + /** @return list */ public function getAttributes(): array; diff --git a/src/Reflection/FunctionReflectionFactory.php b/src/Reflection/FunctionReflectionFactory.php index 993bf34b3b4..58224858714 100644 --- a/src/Reflection/FunctionReflectionFactory.php +++ b/src/Reflection/FunctionReflectionFactory.php @@ -16,6 +16,7 @@ interface FunctionReflectionFactory * @param array $phpDocParameterImmediatelyInvokedCallable * @param array $phpDocParameterClosureThisTypes * @param list $attributes + * @param array $phpDocParameterPureUnlessCallableIsImpure */ public function create( ReflectionFunction $reflection, @@ -35,6 +36,7 @@ public function create( array $phpDocParameterImmediatelyInvokedCallable, array $phpDocParameterClosureThisTypes, array $attributes, + array $phpDocParameterPureUnlessCallableIsImpure, ): PhpFunctionReflection; } diff --git a/src/Reflection/Native/ExtendedNativeParameterReflection.php b/src/Reflection/Native/ExtendedNativeParameterReflection.php index 5539d9132a1..094d647739d 100644 --- a/src/Reflection/Native/ExtendedNativeParameterReflection.php +++ b/src/Reflection/Native/ExtendedNativeParameterReflection.php @@ -31,6 +31,7 @@ public function __construct( private ?Type $closureThisType, private array $attributes, private ?ParameterAllowedConstants $allowedConstants, + private bool $pureUnlessCallableIsImpureParameter = false, ) { } @@ -114,4 +115,9 @@ public function checkAllowedConstants(array $constants): AllowedConstantsResult return $this->allowedConstants->check($constants); } + public function isPureUnlessCallableIsImpureParameter(): bool + { + return $this->pureUnlessCallableIsImpureParameter; + } + } diff --git a/src/Reflection/Native/NativeFunctionReflection.php b/src/Reflection/Native/NativeFunctionReflection.php index 50ddbb0e9b9..77e34d1ab84 100644 --- a/src/Reflection/Native/NativeFunctionReflection.php +++ b/src/Reflection/Native/NativeFunctionReflection.php @@ -110,6 +110,11 @@ public function isPure(): TrinaryLogic return $this->hasSideEffects->negate(); } + public function getPureUnlessCallableIsImpureParameters(): array + { + return []; + } + private function isVoid(): bool { foreach ($this->variants as $variant) { diff --git a/src/Reflection/Native/NativeMethodReflection.php b/src/Reflection/Native/NativeMethodReflection.php index 5cd7475e83e..d614bda8811 100644 --- a/src/Reflection/Native/NativeMethodReflection.php +++ b/src/Reflection/Native/NativeMethodReflection.php @@ -187,6 +187,11 @@ public function isPure(): TrinaryLogic return $this->hasSideEffects->negate(); } + public function getPureUnlessCallableIsImpureParameters(): array + { + return []; + } + private function isVoid(): bool { foreach ($this->variants as $variant) { diff --git a/src/Reflection/ParametersAcceptorSelector.php b/src/Reflection/ParametersAcceptorSelector.php index d918b512f22..e34989a39dd 100644 --- a/src/Reflection/ParametersAcceptorSelector.php +++ b/src/Reflection/ParametersAcceptorSelector.php @@ -768,6 +768,7 @@ public static function combineAcceptors(array $acceptors): ExtendedParametersAcc $parameter instanceof ExtendedParameterReflection ? $parameter->getClosureThisType() : null, $parameter instanceof ExtendedParameterReflection ? $parameter->getAttributes() : [], $parameter instanceof ExtendedParameterReflection ? $parameter->getAllowedConstants() : null, + $parameter instanceof ExtendedParameterReflection && $parameter->isPureUnlessCallableIsImpureParameter(), ); continue; } @@ -822,6 +823,9 @@ public static function combineAcceptors(array $acceptors): ExtendedParametersAcc } } + $pureUnlessCallableIsImpureParameter = $parameters[$i]->isPureUnlessCallableIsImpureParameter() + || ($parameter instanceof ExtendedParameterReflection && $parameter->isPureUnlessCallableIsImpureParameter()); + $parameters[$i] = new ExtendedDummyParameter( $parameters[$i]->getName() !== $parameter->getName() ? sprintf('%s|%s', $parameters[$i]->getName(), $parameter->getName()) : $parameter->getName(), $type, @@ -836,6 +840,7 @@ public static function combineAcceptors(array $acceptors): ExtendedParametersAcc $closureThisType, $attributes, $allowedConstants, + $pureUnlessCallableIsImpureParameter, ); if ($isVariadic) { @@ -1265,6 +1270,7 @@ private static function overrideParameterType(ParameterReflection $original, Typ $wrapped->getClosureThisType(), $wrapped->getAttributes(), $wrapped->getAllowedConstants(), + $wrapped->isPureUnlessCallableIsImpureParameter(), ); } diff --git a/src/Reflection/Php/ClosureCallMethodReflection.php b/src/Reflection/Php/ClosureCallMethodReflection.php index 41c278f08dd..a605c7cab35 100644 --- a/src/Reflection/Php/ClosureCallMethodReflection.php +++ b/src/Reflection/Php/ClosureCallMethodReflection.php @@ -99,6 +99,8 @@ public function getVariants(): array null, [], null, + // pure-unless-callable-is-impure is not threaded here: a closure's own + // parameters cannot carry the tag. ), $parameters), $this->closureType->isVariadic(), $this->closureType->getReturnType(), @@ -199,6 +201,11 @@ public function isPure(): TrinaryLogic return $this->nativeMethodReflection->isPure(); } + public function getPureUnlessCallableIsImpureParameters(): array + { + return $this->nativeMethodReflection->getPureUnlessCallableIsImpureParameters(); + } + public function getAttributes(): array { return $this->nativeMethodReflection->getAttributes(); diff --git a/src/Reflection/Php/EnumCasesMethodReflection.php b/src/Reflection/Php/EnumCasesMethodReflection.php index d731fc29d90..3f829984d8a 100644 --- a/src/Reflection/Php/EnumCasesMethodReflection.php +++ b/src/Reflection/Php/EnumCasesMethodReflection.php @@ -157,6 +157,11 @@ public function isPure(): TrinaryLogic return TrinaryLogic::createYes(); } + public function getPureUnlessCallableIsImpureParameters(): array + { + return []; + } + public function getAttributes(): array { return []; diff --git a/src/Reflection/Php/ExitFunctionReflection.php b/src/Reflection/Php/ExitFunctionReflection.php index 8303f1084f4..bbffd428ce6 100644 --- a/src/Reflection/Php/ExitFunctionReflection.php +++ b/src/Reflection/Php/ExitFunctionReflection.php @@ -139,6 +139,11 @@ public function isPure(): TrinaryLogic return TrinaryLogic::createNo(); } + public function getPureUnlessCallableIsImpureParameters(): array + { + return []; + } + public function getAttributes(): array { return []; diff --git a/src/Reflection/Php/ExtendedDummyParameter.php b/src/Reflection/Php/ExtendedDummyParameter.php index 69a19ccbf3a..7ec8a16105e 100644 --- a/src/Reflection/Php/ExtendedDummyParameter.php +++ b/src/Reflection/Php/ExtendedDummyParameter.php @@ -31,6 +31,7 @@ public function __construct( private ?Type $closureThisType, private array $attributes, private ?ParameterAllowedConstants $allowedConstants, + private bool $pureUnlessCallableIsImpureParameter = false, ) { parent::__construct($name, $type, $optional, $passedByReference, $variadic, $defaultValue); @@ -85,4 +86,9 @@ public function checkAllowedConstants(array $constants): AllowedConstantsResult return $this->allowedConstants->check($constants); } + public function isPureUnlessCallableIsImpureParameter(): bool + { + return $this->pureUnlessCallableIsImpureParameter; + } + } diff --git a/src/Reflection/Php/PhpClassReflectionExtension.php b/src/Reflection/Php/PhpClassReflectionExtension.php index c5c7ed2b68a..df72513078c 100644 --- a/src/Reflection/Php/PhpClassReflectionExtension.php +++ b/src/Reflection/Php/PhpClassReflectionExtension.php @@ -636,7 +636,9 @@ private function createMethod( $isPure = null; if ($this->signatureMapProvider->hasMethodMetadata($declaringClassName, $methodReflection->getName())) { - $isPure = !$this->signatureMapProvider->getMethodMetadata($declaringClassName, $methodReflection->getName())['hasSideEffects']; + $methodMetadata = $this->signatureMapProvider->getMethodMetadata($declaringClassName, $methodReflection->getName()); + $hasSideEffects = $methodMetadata['hasSideEffects'] ?? true; + $isPure = !$hasSideEffects; } $methodSignaturesResult = $this->signatureMapProvider->getMethodSignatures($declaringClassName, $methodReflection->getName(), $methodReflection); @@ -882,11 +884,14 @@ public function createUserlandMethodReflection(ClassReflection $fileDeclaringCla ); $isPure = null; + $pureUnlessCallableIsImpureParameters = []; if ($actualDeclaringClass->isBuiltin() || $actualDeclaringClass->isEnum()) { foreach (array_keys($actualDeclaringClass->getAncestors()) as $className) { if ($this->signatureMapProvider->hasMethodMetadata($className, $methodReflection->getName())) { - $hasSideEffects = $this->signatureMapProvider->getMethodMetadata($className, $methodReflection->getName())['hasSideEffects']; + $methodMetadata = $this->signatureMapProvider->getMethodMetadata($className, $methodReflection->getName()); + $hasSideEffects = $methodMetadata['hasSideEffects'] ?? true; $isPure = !$hasSideEffects; + $pureUnlessCallableIsImpureParameters += $methodMetadata['pureUnlessCallableIsImpureParameters'] ?? []; break; } @@ -909,6 +914,9 @@ public function createUserlandMethodReflection(ClassReflection $fileDeclaringCla $templateTypeMap = $resolvedPhpDoc->getTemplateTypeMap(); $immediatelyInvokedCallableParameters = array_map(static fn (bool $immediate) => TrinaryLogic::createFromBoolean($immediate), $resolvedPhpDoc->getParamsImmediatelyInvokedCallable()); $closureThisParameters = array_map(static fn ($tag) => $tag->getType(), $resolvedPhpDoc->getParamClosureThisTags()); + foreach ($resolvedPhpDoc->getParamsPureUnlessCallableIsImpure() as $paramName => $isPureUnlessCallableIsImpure) { + $pureUnlessCallableIsImpureParameters[$paramName] = $isPureUnlessCallableIsImpure; + } $phpDocReturnType = $this->getPhpDocReturnType($phpDocBlockClassReflection, $resolvedPhpDoc, $nativeReturnType); $phpDocThrowType = $resolvedPhpDoc->getThrowsTag() !== null ? $resolvedPhpDoc->getThrowsTag()->getType() : null; foreach ($resolvedPhpDoc->getParamTags() as $paramName => $paramTag) { @@ -988,6 +996,7 @@ public function createUserlandMethodReflection(ClassReflection $fileDeclaringCla $closureThisParameters, $acceptsNamedArguments, $this->attributeReflectionFactory->fromNativeReflection($methodReflection->getAttributes(), InitializerExprContext::fromClassMethod($actualDeclaringClass->getName(), $declaringTraitName, $methodReflection->getName(), $actualDeclaringClass->getFileName())), + $pureUnlessCallableIsImpureParameters, ); } @@ -1056,6 +1065,8 @@ 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). ); } @@ -1163,7 +1174,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] = $this->nodeScopeResolver->getPhpDocs($classScope, $methodNode); + [$templateTypeMap, $phpDocParameterTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $phpDocReturnType, $phpDocThrowType, $deprecatedDescription, $isDeprecated, $isInternal, $isFinal, $isPure, $acceptsNamedArguments, , $phpDocComment, $asserts, $selfOutType, $phpDocParameterOutTypes, , , , $phpDocPureUnlessCallableIsImpureParameters] = $this->nodeScopeResolver->getPhpDocs($classScope, $methodNode); $methodScope = $classScope->enterClassMethod( $methodNode, $templateTypeMap, @@ -1182,6 +1193,9 @@ private function inferAndCachePropertyTypes( $phpDocParameterOutTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, + false, + null, + $phpDocPureUnlessCallableIsImpureParameters, ); $propertyTypes = []; diff --git a/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php b/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php index ba4e66fa1b9..26623a19a2d 100644 --- a/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php +++ b/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php @@ -48,6 +48,7 @@ class PhpFunctionFromParserNodeReflection implements FunctionReflection, Extende * @param array $immediatelyInvokedCallableParameters * @param array $phpDocClosureThisTypeParameters * @param list $attributes + * @param array $pureUnlessCallableIsImpureParameters */ public function __construct( FunctionLike $functionLike, @@ -71,6 +72,7 @@ public function __construct( private array $immediatelyInvokedCallableParameters, private array $phpDocClosureThisTypeParameters, private array $attributes, + private array $pureUnlessCallableIsImpureParameters, ) { $this->functionLike = $functionLike; @@ -177,6 +179,8 @@ public function getParameters(): array $closureThisType = null; } + $pureUnlessCallableIsImpureParameter = $this->pureUnlessCallableIsImpureParameters[$parameter->var->name] ?? false; + $parameters[] = new PhpParameterFromParserNodeReflection( $parameter->var->name, $isOptional, @@ -191,6 +195,7 @@ public function getParameters(): array $immediatelyInvokedCallable, $closureThisType, $this->parameterAttributes[$parameter->var->name] ?? [], + $pureUnlessCallableIsImpureParameter, ); } @@ -334,6 +339,11 @@ public function isPure(): TrinaryLogic return TrinaryLogic::createFromBoolean($this->isPure); } + public function getPureUnlessCallableIsImpureParameters(): array + { + return $this->pureUnlessCallableIsImpureParameters; + } + public function getAttributes(): array { return $this->attributes; diff --git a/src/Reflection/Php/PhpFunctionReflection.php b/src/Reflection/Php/PhpFunctionReflection.php index 2dcb0c7b870..0d7fac1ceef 100644 --- a/src/Reflection/Php/PhpFunctionReflection.php +++ b/src/Reflection/Php/PhpFunctionReflection.php @@ -40,6 +40,7 @@ final class PhpFunctionReflection implements FunctionReflection * @param array $phpDocParameterImmediatelyInvokedCallable * @param array $phpDocParameterClosureThisTypes * @param list $attributes + * @param array $phpDocParameterPureUnlessCallableIsImpure */ public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, @@ -62,6 +63,7 @@ public function __construct( private array $phpDocParameterImmediatelyInvokedCallable, private array $phpDocParameterClosureThisTypes, private array $attributes, + private array $phpDocParameterPureUnlessCallableIsImpure, ) { } @@ -130,6 +132,7 @@ private function getParameters(): array $this->phpDocParameterClosureThisTypes[$reflection->getName()] ?? null, $this->attributeReflectionFactory->fromNativeReflection($reflection->getAttributes(), InitializerExprContext::fromReflectionParameter($reflection)), $this->allowedConstantsMapProvider->getForFunctionParameter(strtolower($this->reflection->getName()), $reflection->getName()), + $this->phpDocParameterPureUnlessCallableIsImpure[$reflection->getName()] ?? false, ); }, $this->reflection->getParameters()); } @@ -213,6 +216,11 @@ public function isPure(): TrinaryLogic return TrinaryLogic::createFromBoolean($this->isPure); } + public function getPureUnlessCallableIsImpureParameters(): array + { + return $this->phpDocParameterPureUnlessCallableIsImpure; + } + public function isBuiltin(): bool { return $this->reflection->isInternal(); diff --git a/src/Reflection/Php/PhpMethodFromParserNodeReflection.php b/src/Reflection/Php/PhpMethodFromParserNodeReflection.php index 25aa5fefa8b..290e0cb4b76 100644 --- a/src/Reflection/Php/PhpMethodFromParserNodeReflection.php +++ b/src/Reflection/Php/PhpMethodFromParserNodeReflection.php @@ -72,6 +72,7 @@ public function __construct( array $phpDocClosureThisTypeParameters, private bool $isConstructor, array $attributes, + array $pureUnlessCallableIsImpureParameters, ) { if ($this->classMethod instanceof Node\PropertyHook) { @@ -138,6 +139,7 @@ public function __construct( $immediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $attributes, + $pureUnlessCallableIsImpureParameters, ); } diff --git a/src/Reflection/Php/PhpMethodReflection.php b/src/Reflection/Php/PhpMethodReflection.php index d24532340d4..3ee602c37dd 100644 --- a/src/Reflection/Php/PhpMethodReflection.php +++ b/src/Reflection/Php/PhpMethodReflection.php @@ -63,6 +63,7 @@ final class PhpMethodReflection implements ExtendedMethodReflection * @param array $immediatelyInvokedCallableParameters * @param array $phpDocClosureThisTypeParameters * @param list $attributes + * @param array $pureUnlessCallableIsImpureParameters */ public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, @@ -90,6 +91,7 @@ public function __construct( private array $immediatelyInvokedCallableParameters, private array $phpDocClosureThisTypeParameters, private array $attributes, + private array $pureUnlessCallableIsImpureParameters, ) { } @@ -229,6 +231,7 @@ private function getParameters(): array $this->phpDocClosureThisTypeParameters[$reflection->getName()] ?? null, $this->attributeReflectionFactory->fromNativeReflection($reflection->getAttributes(), InitializerExprContext::fromReflectionParameter($reflection)), $this->allowedConstantsMapProvider->getForMethodParameter($this->declaringClass->getName(), $this->reflection->getName(), $reflection->getName()), + $this->pureUnlessCallableIsImpureParameters[$reflection->getName()] ?? false, ), $this->reflection->getParameters()); } @@ -405,6 +408,11 @@ public function isPure(): TrinaryLogic return TrinaryLogic::createFromBoolean($this->isPure); } + public function getPureUnlessCallableIsImpureParameters(): array + { + return $this->pureUnlessCallableIsImpureParameters; + } + public function changePropertyGetHookPhpDocType(Type $phpDocType): self { return new self( @@ -433,6 +441,7 @@ public function changePropertyGetHookPhpDocType(Type $phpDocType): self $this->immediatelyInvokedCallableParameters, $this->phpDocClosureThisTypeParameters, $this->attributes, + $this->pureUnlessCallableIsImpureParameters, ); } @@ -467,6 +476,7 @@ public function changePropertySetHookPhpDocType(string $parameterName, Type $php $this->immediatelyInvokedCallableParameters, $this->phpDocClosureThisTypeParameters, $this->attributes, + $this->pureUnlessCallableIsImpureParameters, ); } diff --git a/src/Reflection/Php/PhpMethodReflectionFactory.php b/src/Reflection/Php/PhpMethodReflectionFactory.php index 6ac366fc1b4..c2978529d86 100644 --- a/src/Reflection/Php/PhpMethodReflectionFactory.php +++ b/src/Reflection/Php/PhpMethodReflectionFactory.php @@ -20,6 +20,7 @@ interface PhpMethodReflectionFactory * @param array $immediatelyInvokedCallableParameters * @param array $phpDocClosureThisTypeParameters * @param list $attributes + * @param array $pureUnlessCallableIsImpureParameters */ public function create( ClassReflection $declaringClass, @@ -43,6 +44,7 @@ public function create( array $phpDocClosureThisTypeParameters, bool $acceptsNamedArguments, array $attributes, + array $pureUnlessCallableIsImpureParameters, ): PhpMethodReflection; } diff --git a/src/Reflection/Php/PhpParameterFromParserNodeReflection.php b/src/Reflection/Php/PhpParameterFromParserNodeReflection.php index 7061d7f63e9..78939b3c82b 100644 --- a/src/Reflection/Php/PhpParameterFromParserNodeReflection.php +++ b/src/Reflection/Php/PhpParameterFromParserNodeReflection.php @@ -33,6 +33,7 @@ public function __construct( private TrinaryLogic $immediatelyInvokedCallable, private ?Type $closureThisType, private array $attributes, + private bool $pureUnlessCallableIsImpureParameter, ) { } @@ -125,4 +126,9 @@ public function checkAllowedConstants(array $constants): AllowedConstantsResult return new AllowedConstantsResult([], [], false); } + public function isPureUnlessCallableIsImpureParameter(): bool + { + return $this->pureUnlessCallableIsImpureParameter; + } + } diff --git a/src/Reflection/Php/PhpParameterReflection.php b/src/Reflection/Php/PhpParameterReflection.php index 17b55295159..2838a8dc9d9 100644 --- a/src/Reflection/Php/PhpParameterReflection.php +++ b/src/Reflection/Php/PhpParameterReflection.php @@ -37,6 +37,7 @@ public function __construct( private ?Type $closureThisType, private array $attributes, private ?ParameterAllowedConstants $allowedConstants, + private bool $pureUnlessCallableIsImpureParameter = false, ) { } @@ -160,4 +161,9 @@ public function checkAllowedConstants(array $constants): AllowedConstantsResult return $this->allowedConstants->check($constants); } + public function isPureUnlessCallableIsImpureParameter(): bool + { + return $this->pureUnlessCallableIsImpureParameter; + } + } diff --git a/src/Reflection/ResolvedFunctionVariantWithOriginal.php b/src/Reflection/ResolvedFunctionVariantWithOriginal.php index 9d451a57148..0151110214b 100644 --- a/src/Reflection/ResolvedFunctionVariantWithOriginal.php +++ b/src/Reflection/ResolvedFunctionVariantWithOriginal.php @@ -122,6 +122,7 @@ function (ExtendedParameterReflection $param): ExtendedParameterReflection { $closureThisType, $param->getAttributes(), $param->getAllowedConstants(), + $param->isPureUnlessCallableIsImpureParameter(), ); }, $this->parametersAcceptor->getParameters(), diff --git a/src/Reflection/ResolvedMethodReflection.php b/src/Reflection/ResolvedMethodReflection.php index 151b3372894..4edd20227b1 100644 --- a/src/Reflection/ResolvedMethodReflection.php +++ b/src/Reflection/ResolvedMethodReflection.php @@ -168,6 +168,11 @@ public function isPure(): TrinaryLogic return $this->reflection->isPure(); } + public function getPureUnlessCallableIsImpureParameters(): array + { + return $this->reflection->getPureUnlessCallableIsImpureParameters(); + } + public function getAsserts(): Assertions { return $this->asserts ??= $this->reflection->getAsserts()->mapTypes(fn (Type $type) => TemplateTypeHelper::resolveTemplateTypes( diff --git a/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php b/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php index efb3b508db3..a13012b14ba 100644 --- a/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php +++ b/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php @@ -110,18 +110,30 @@ public function findFunctionReflection(string $functionName): ?NativeFunctionRef } $allowedConstantsMapProvider = $this->allowedConstantsMapProvider; + $pureUnlessCallableIsImpureParameters = []; + if ($this->signatureMapProvider->hasFunctionMetadata($lowerCasedFunctionName)) { + $functionMetadata = $this->signatureMapProvider->getFunctionMetadata($lowerCasedFunctionName); + if (isset($functionMetadata['pureUnlessCallableIsImpureParameters'])) { + $pureUnlessCallableIsImpureParameters = $functionMetadata['pureUnlessCallableIsImpureParameters']; + } + } else { + $functionMetadata = null; + } + $variantsByType = ['positional' => []]; foreach ($functionSignaturesResult as $signatureType => $functionSignatures) { foreach ($functionSignatures ?? [] as $functionSignature) { $variantsByType[$signatureType][] = new ExtendedFunctionVariant( TemplateTypeMap::createEmpty(), null, - array_map(static function (ParameterSignature $parameterSignature) use ($phpDoc, $lowerCasedFunctionName, $allowedConstantsMapProvider): ExtendedNativeParameterReflection { + array_map(static function (ParameterSignature $parameterSignature) use ($phpDoc, $lowerCasedFunctionName, $allowedConstantsMapProvider, $pureUnlessCallableIsImpureParameters): ExtendedNativeParameterReflection { + $name = $parameterSignature->getName(); $type = $parameterSignature->getType(); $phpDocType = null; $immediatelyInvokedCallable = TrinaryLogic::createMaybe(); $closureThisType = null; + $pureUnlessCallableIsImpureParameter = $pureUnlessCallableIsImpureParameters[$name] ?? false; if ($phpDoc !== null) { if (array_key_exists($parameterSignature->getName(), $phpDoc->getParamTags())) { $phpDocType = $phpDoc->getParamTags()[$parameterSignature->getName()]->getType(); @@ -132,6 +144,9 @@ public function findFunctionReflection(string $functionName): ?NativeFunctionRef if (array_key_exists($parameterSignature->getName(), $phpDoc->getParamClosureThisTags())) { $closureThisType = $phpDoc->getParamClosureThisTags()[$parameterSignature->getName()]->getType(); } + if (($phpDoc->getParamsPureUnlessCallableIsImpure()[$parameterSignature->getName()] ?? false) === true) { + $pureUnlessCallableIsImpureParameter = true; + } } return new ExtendedNativeParameterReflection( @@ -148,6 +163,7 @@ public function findFunctionReflection(string $functionName): ?NativeFunctionRef $closureThisType, [], $allowedConstantsMapProvider->getForFunctionParameter($lowerCasedFunctionName, $parameterSignature->getName()), + $pureUnlessCallableIsImpureParameter, ); }, $functionSignature->getParameters()), $functionSignature->isVariadic(), @@ -158,11 +174,9 @@ public function findFunctionReflection(string $functionName): ?NativeFunctionRef } } - if ($this->signatureMapProvider->hasFunctionMetadata($lowerCasedFunctionName)) { - $hasSideEffects = TrinaryLogic::createFromBoolean($this->signatureMapProvider->getFunctionMetadata($lowerCasedFunctionName)['hasSideEffects']); - } else { - $hasSideEffects = TrinaryLogic::createMaybe(); - } + $hasSideEffects = isset($functionMetadata['hasSideEffects']) + ? TrinaryLogic::createFromBoolean($functionMetadata['hasSideEffects']) + : TrinaryLogic::createMaybe(); $functionReflection = new NativeFunctionReflection( $realFunctionName, diff --git a/src/Reflection/SignatureMap/SignatureMapProvider.php b/src/Reflection/SignatureMap/SignatureMapProvider.php index 3999d919b8f..8afcf9b1b1a 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} + * @return array{hasSideEffects?: bool, pureUnlessCallableIsImpureParameters?: array} */ public function getMethodMetadata(string $className, string $methodName): array; /** - * @return array{hasSideEffects: bool} + * @return array{hasSideEffects?: bool, pureUnlessCallableIsImpureParameters?: array} */ public function getFunctionMetadata(string $functionName): array; diff --git a/src/Reflection/Type/CallbackUnresolvedMethodPrototypeReflection.php b/src/Reflection/Type/CallbackUnresolvedMethodPrototypeReflection.php index 026d3c36fb2..72b5def04fe 100644 --- a/src/Reflection/Type/CallbackUnresolvedMethodPrototypeReflection.php +++ b/src/Reflection/Type/CallbackUnresolvedMethodPrototypeReflection.php @@ -119,6 +119,7 @@ function (ExtendedParameterReflection $parameter): ExtendedParameterReflection { $parameter->getClosureThisType() !== null ? $this->transformStaticType($parameter->getClosureThisType()) : null, $parameter->getAttributes(), $parameter->getAllowedConstants(), + $parameter->isPureUnlessCallableIsImpureParameter(), ); }, $acceptor->getParameters(), diff --git a/src/Reflection/Type/CalledOnTypeUnresolvedMethodPrototypeReflection.php b/src/Reflection/Type/CalledOnTypeUnresolvedMethodPrototypeReflection.php index 8198ea1f954..8e6099d8886 100644 --- a/src/Reflection/Type/CalledOnTypeUnresolvedMethodPrototypeReflection.php +++ b/src/Reflection/Type/CalledOnTypeUnresolvedMethodPrototypeReflection.php @@ -106,6 +106,7 @@ private function transformMethodWithStaticType(ClassReflection $declaringClass, $parameter->getClosureThisType() !== null ? $this->transformStaticType($parameter->getClosureThisType()) : null, $parameter->getAttributes(), $parameter->getAllowedConstants(), + $parameter->isPureUnlessCallableIsImpureParameter(), ), $acceptor->getParameters(), ), diff --git a/src/Reflection/Type/IntersectionTypeMethodReflection.php b/src/Reflection/Type/IntersectionTypeMethodReflection.php index 7aab93fa82d..154126e0229 100644 --- a/src/Reflection/Type/IntersectionTypeMethodReflection.php +++ b/src/Reflection/Type/IntersectionTypeMethodReflection.php @@ -214,6 +214,11 @@ public function isPure(): TrinaryLogic return TrinaryLogic::lazyMaxMin($this->methods, static fn (ExtendedMethodReflection $method): TrinaryLogic => $method->isPure()); } + public function getPureUnlessCallableIsImpureParameters(): array + { + return $this->getMethodWithMostParameters()->getPureUnlessCallableIsImpureParameters(); + } + public function getDocComment(): ?string { return null; diff --git a/src/Reflection/Type/UnionTypeMethodReflection.php b/src/Reflection/Type/UnionTypeMethodReflection.php index 89557b4e2c0..584376eadab 100644 --- a/src/Reflection/Type/UnionTypeMethodReflection.php +++ b/src/Reflection/Type/UnionTypeMethodReflection.php @@ -171,6 +171,11 @@ public function isPure(): TrinaryLogic return TrinaryLogic::lazyExtremeIdentity($this->methods, static fn (ExtendedMethodReflection $method): TrinaryLogic => $method->isPure()); } + public function getPureUnlessCallableIsImpureParameters(): array + { + return $this->methods[0]->getPureUnlessCallableIsImpureParameters(); + } + public function getDocComment(): ?string { return null; diff --git a/src/Reflection/WrappedExtendedMethodReflection.php b/src/Reflection/WrappedExtendedMethodReflection.php index 7711a799580..2d35ce62d0e 100644 --- a/src/Reflection/WrappedExtendedMethodReflection.php +++ b/src/Reflection/WrappedExtendedMethodReflection.php @@ -145,6 +145,11 @@ public function isPure(): TrinaryLogic return TrinaryLogic::createMaybe(); } + public function getPureUnlessCallableIsImpureParameters(): array + { + return []; + } + public function getAsserts(): Assertions { return Assertions::createEmpty(); diff --git a/src/Rules/RestrictedUsage/RewrittenDeclaringClassMethodReflection.php b/src/Rules/RestrictedUsage/RewrittenDeclaringClassMethodReflection.php index 43fc7ffaedc..f49c197a554 100644 --- a/src/Rules/RestrictedUsage/RewrittenDeclaringClassMethodReflection.php +++ b/src/Rules/RestrictedUsage/RewrittenDeclaringClassMethodReflection.php @@ -101,6 +101,11 @@ public function isPure(): TrinaryLogic return $this->methodReflection->isPure(); } + public function getPureUnlessCallableIsImpureParameters(): array + { + return $this->methodReflection->getPureUnlessCallableIsImpureParameters(); + } + public function getAttributes(): array { return $this->methodReflection->getAttributes(); diff --git a/stubs/arrayFunctions.stub b/stubs/arrayFunctions.stub index 3297e473168..aff4251d517 100644 --- a/stubs/arrayFunctions.stub +++ b/stubs/arrayFunctions.stub @@ -9,6 +9,8 @@ * @param TReturn $three * * @return TReturn + * + * @pure-unless-callable-is-impure $two */ function array_reduce( array $one, diff --git a/tests/PHPStan/Analyser/TestClosureTypeRuleTest.php b/tests/PHPStan/Analyser/TestClosureTypeRuleTest.php index aebfdc606f4..a095b96ac40 100644 --- a/tests/PHPStan/Analyser/TestClosureTypeRuleTest.php +++ b/tests/PHPStan/Analyser/TestClosureTypeRuleTest.php @@ -21,11 +21,11 @@ public function testRule(): void $this->analyse([__DIR__ . '/nsrt/closure-passed-to-type.php'], [ [ 'Closure type: Closure(mixed): (1|2|3)', - 25, + 26, ], [ 'Closure type: Closure(mixed): (1|2|3)', - 35, + 36, ], ]); } diff --git a/tests/PHPStan/Analyser/nsrt/closure-passed-to-type.php b/tests/PHPStan/Analyser/nsrt/closure-passed-to-type.php index a34ded15919..f3fa9795d2b 100644 --- a/tests/PHPStan/Analyser/nsrt/closure-passed-to-type.php +++ b/tests/PHPStan/Analyser/nsrt/closure-passed-to-type.php @@ -13,6 +13,7 @@ class Foo * @param array $items * @param callable(T): U $cb * @return array + * @pure-unless-callable-impure $cb */ public function doFoo(array $items, callable $cb) { diff --git a/tests/PHPStan/Analyser/nsrt/param-pure-unless-callable-is-impure.php b/tests/PHPStan/Analyser/nsrt/param-pure-unless-callable-is-impure.php new file mode 100644 index 00000000000..4ddd3212822 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/param-pure-unless-callable-is-impure.php @@ -0,0 +1,28 @@ + $a + * @return array + * @pure-unless-callable-is-impure $f + */ +function map(Closure $f, iterable $a): array +{ + $result = []; + foreach ($a as $i => $v) { + $retult[$i] = $f($v); + } + + return $result; +} + +map('printf', []); +map('sprintf', []); + +assertType('array', map('printf', [])); diff --git a/tests/PHPStan/Command/data/file-without-errors.php b/tests/PHPStan/Command/data/file-without-errors.php index 08929907d3c..48196273364 100644 --- a/tests/PHPStan/Command/data/file-without-errors.php +++ b/tests/PHPStan/Command/data/file-without-errors.php @@ -1,3 +1,3 @@ process(Expect::arrayOf( Expect::structure([ - 'hasSideEffects' => Expect::bool()->required(), - ])->required(), + 'hasSideEffects' => Expect::bool(), + 'pureUnlessCallableIsImpureParameters' => Expect::arrayOf(Expect::bool(), Expect::string()), + ]) + ->assert(static fn ($v) => count((array) $v) > 0, 'Metadata entries must not be empty.') + ->required(), )->required(), $data); } diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionStatementWithoutSideEffectsRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionStatementWithoutSideEffectsRuleTest.php index 4c40e6d7fcc..1e02b374418 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionStatementWithoutSideEffectsRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionStatementWithoutSideEffectsRuleTest.php @@ -91,6 +91,28 @@ public function testBug12224(): void $this->analyse([__DIR__ . '/data/bug-12224.php'], []); } + public function testBug11101(): void + { + $this->analyse([__DIR__ . '/data/bug-11101.php'], [ + [ + 'Call to function array_filter() on a separate line has no effect.', + 12, + ], + [ + 'Call to function array_map() on a separate line has no effect.', + 13, + ], + [ + 'Call to function array_reduce() on a separate line has no effect.', + 14, + ], + [ + 'Call to function array_filter() on a separate line has no effect.', + 15, + ], + ]); + } + public function testBug4455(): void { require_once __DIR__ . '/data/bug-4455.php'; diff --git a/tests/PHPStan/Rules/Functions/data/bug-11101.php b/tests/PHPStan/Rules/Functions/data/bug-11101.php new file mode 100644 index 00000000000..0c1143e5b0d --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-11101.php @@ -0,0 +1,23 @@ + $array + * @param callable(int): bool $opaque + * @param pure-callable(int): int $pureCb + */ +function doFoo(array $array, callable $opaque, callable $pureCb): void +{ + array_filter($array, 'is_string'); + array_map('is_string', $array); + array_reduce($array, fn ($c, $i) => $c + $i, 0); + array_filter($array); + + array_map(static function (int $x): int { + echo $x; + return $x; + }, $array); + array_map($opaque, $array); + usort($array, $pureCb); +} diff --git a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php index cb9afa117a6..0e2b52fb20b 100644 --- a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php +++ b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php @@ -230,4 +230,81 @@ public function testBug6574(): void $this->analyse([__DIR__ . '/data/bug-6574.php'], []); } + public function testPureUnlessCallableIsImpure(): void + { + $this->analyse([__DIR__ . '/data/pure-unless-callable-is-impure.php'], [ + [ + 'Impure call to function array_map() in pure function PureUnlessCallableIsImpureFunction\pureWithImpureCallback().', + 22, + ], + [ + 'Impure echo in pure function PureUnlessCallableIsImpureFunction\pureWithImpureCallback().', + 23, + ], + [ + 'Possibly impure call to a callable in pure function PureUnlessCallableIsImpureFunction\pureWithOpaqueCallback().', + 36, + ], + [ + 'Possibly impure call to function array_map() in pure function PureUnlessCallableIsImpureFunction\pureWithOpaqueCallback().', + 36, + ], + [ + 'Impure call to method PureUnlessCallableIsImpureFunction\Mapper::map() in pure function PureUnlessCallableIsImpureFunction\pureCallingMethodWithImpureCallback().', + 129, + ], + [ + 'Possibly impure call to method PureUnlessCallableIsImpureFunction\Mapper::map() in pure function PureUnlessCallableIsImpureFunction\pureCallingMethodWithOpaqueCallback().', + 143, + ], + [ + 'Possibly impure call to function array_map() in pure function PureUnlessCallableIsImpureFunction\pureWithMaybeNullCallback().', + 155, + ], + [ + 'Possibly impure call to function array_map() in pure function PureUnlessCallableIsImpureFunction\pureWithMaybeCallablePureCallback().', + 167, + ], + [ + 'Impure instantiation of class PureUnlessCallableIsImpureFunction\Baz in pure function PureUnlessCallableIsImpureFunction\pureInstantiatingWithImpureCallback().', + 200, + ], + [ + 'Possibly impure instantiation of class PureUnlessCallableIsImpureFunction\Baz in pure function PureUnlessCallableIsImpureFunction\pureInstantiatingWithOpaqueCallback().', + 213, + ], + ]); + } + + #[RequiresPhp('>= 8.4.0')] + public function testPureUnlessCallableIsImpurePhp84(): void + { + $this->analyse([__DIR__ . '/data/pure-unless-callable-is-impure-php84.php'], [ + [ + 'Impure call to function array_any() in pure function PureUnlessCallableIsImpureFunctionPhp84\anyWithImpureCallback().', + 29, + ], + [ + 'Impure echo in pure function PureUnlessCallableIsImpureFunctionPhp84\anyWithImpureCallback().', + 30, + ], + [ + 'Impure call to function array_find() in pure function PureUnlessCallableIsImpureFunctionPhp84\findWithImpureCallback().', + 59, + ], + [ + 'Impure echo in pure function PureUnlessCallableIsImpureFunctionPhp84\findWithImpureCallback().', + 60, + ], + [ + 'Impure call to function array_find_key() in pure function PureUnlessCallableIsImpureFunctionPhp84\findKeyWithImpureCallback().', + 71, + ], + [ + 'Impure echo in pure function PureUnlessCallableIsImpureFunctionPhp84\findKeyWithImpureCallback().', + 72, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php b/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php index 8287809407a..a31dcfe0bd0 100644 --- a/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php +++ b/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php @@ -383,6 +383,12 @@ public function testBug14504(): void $this->analyse([__DIR__ . '/data/bug-14504-method.php'], []); } + public function testBug11100(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-11100.php'], []); + } + public function testBug14511(): void { $this->treatPhpDocTypesAsCertain = true; diff --git a/tests/PHPStan/Rules/Pure/data/bug-11100.php b/tests/PHPStan/Rules/Pure/data/bug-11100.php new file mode 100644 index 00000000000..13793140ef8 --- /dev/null +++ b/tests/PHPStan/Rules/Pure/data/bug-11100.php @@ -0,0 +1,18 @@ + $numbers + * @return array + * @phpstan-pure + */ + public function double(array $numbers): array + { + return array_map(static fn (int $x): int => $x * 2, $numbers); + } + +} diff --git a/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure-php84.php b/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure-php84.php new file mode 100644 index 00000000000..b9d33d09761 --- /dev/null +++ b/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure-php84.php @@ -0,0 +1,75 @@ += 8.4 + +namespace PureUnlessCallableIsImpureFunctionPhp84; + +/** + * @param array $arr + * @phpstan-pure + */ +function anyWithPureCallback(array $arr): bool +{ + return array_any($arr, static fn (int $x): bool => $x > 0); +} + +/** + * @param array $arr + * @phpstan-pure + */ +function allWithPureCallback(array $arr): bool +{ + return array_all($arr, static fn (int $x): bool => $x > 0); +} + +/** + * @param array $arr + * @phpstan-pure + */ +function anyWithImpureCallback(array $arr): bool +{ + return array_any($arr, static function (int $x): bool { + echo $x; + return $x > 0; + }); +} + +/** + * @param array $arr + * @phpstan-pure + */ +function findWithPureCallback(array $arr): ?int +{ + return array_find($arr, static fn (int $x): bool => $x > 0); +} + +/** + * @param array $arr + * @phpstan-pure + */ +function findKeyWithPureCallback(array $arr): int|string|null +{ + return array_find_key($arr, static fn (int $x): bool => $x > 0); +} + +/** + * @param array $arr + * @phpstan-pure + */ +function findWithImpureCallback(array $arr): ?int +{ + return array_find($arr, static function (int $x): bool { + echo $x; + return $x > 0; + }); +} + +/** + * @param array $arr + * @phpstan-pure + */ +function findKeyWithImpureCallback(array $arr): int|string|null +{ + return array_find_key($arr, static function (int $x): bool { + echo $x; + return $x > 0; + }); +} diff --git a/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php b/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php new file mode 100644 index 00000000000..4371259d644 --- /dev/null +++ b/tests/PHPStan/Rules/Pure/data/pure-unless-callable-is-impure.php @@ -0,0 +1,216 @@ + $arr + * @return array + * @phpstan-pure + */ +function pureWithPureCallback(array $arr): array +{ + return array_map(static fn (int $x): int => $x * 2, $arr); +} + +/** + * @param array $arr + * @return array + * @phpstan-pure + */ +function pureWithImpureCallback(array $arr): array +{ + return array_map(static function (int $x): int { + echo $x; + return $x * 2; + }, $arr); +} + +/** + * @param array $arr + * @param callable(int): int $cb + * @return array + * @phpstan-pure + */ +function pureWithOpaqueCallback(array $arr, callable $cb): array +{ + return array_map($cb, $arr); +} + +/** + * @param callable(int): int $f + * @param array $arr + * @return array + * @pure-unless-callable-is-impure $f + */ +function myMap(callable $f, array $arr): array +{ + $result = []; + foreach ($arr as $i => $v) { + $result[$i] = $f($v); + } + + return $result; +} + +/** + * @param callable(int): int $f + * @param array $arr + * @return array + * @phpstan-pure-unless-callable-is-impure $f + */ +function myMapPhpstanAlias(callable $f, array $arr): array +{ + $result = []; + foreach ($arr as $i => $v) { + $result[$i] = $f($v); + } + + return $result; +} + +/** + * @param array $arr + * @return array + * @phpstan-pure + */ +function pureCallingUserlandWithPureCallback(array $arr): array +{ + return myMap(static fn (int $x): int => $x * 2, $arr); +} + +/** + * @param array $arr + * @return array + * @phpstan-pure + */ +function pureCallingUserlandAliasWithPureCallback(array $arr): array +{ + return myMapPhpstanAlias(static fn (int $x): int => $x * 2, $arr); +} + +class Mapper +{ + + /** + * @param callable(int): int $f + * @param array $arr + * @return array + * @pure-unless-callable-is-impure $f + */ + public function map(callable $f, array $arr): array + { + $result = []; + foreach ($arr as $i => $v) { + $result[$i] = $f($v); + } + + return $result; + } + +} + +/** + * @param array $arr + * @return array + * @phpstan-pure + */ +function pureCallingMethodWithPureCallback(Mapper $mapper, array $arr): array +{ + return $mapper->map(static fn (int $x): int => $x * 2, $arr); +} + +/** + * @param array $arr + * @return array + * @phpstan-pure + */ +function pureCallingMethodWithImpureCallback(Mapper $mapper, array $arr): array +{ + return $mapper->map(static function (int $x): int { + echo $x; + return $x * 2; + }, $arr); +} + +/** + * @param array $arr + * @param callable(int): int $cb + * @return array + * @phpstan-pure + */ +function pureCallingMethodWithOpaqueCallback(Mapper $mapper, array $arr, callable $cb): array +{ + return $mapper->map($cb, $arr); +} + +/** + * @param array $arr + * @param (callable(int): int)|null $cb + * @return array + * @phpstan-pure + */ +function pureWithMaybeNullCallback(array $arr, ?callable $cb): array +{ + // $cb might be null (pure) or an unknown callable, so the call stays possibly impure. + return array_map($cb, $arr); +} + +/** + * @param array $arr + * @param (pure-callable(int): int)|int $cb + * @return array + * @phpstan-pure + */ +function pureWithMaybeCallablePureCallback(array $arr, $cb): array +{ + // $cb is only maybe-callable, so the call stays possibly impure. + return array_map($cb, $arr); +} + +class Baz +{ + + protected $x; + + /** + * @pure-unless-callable-is-impure $f + */ + public function __construct(callable $f) + { + $this->x = $f(1); + } + +} + +/** + * @phpstan-pure + */ +function pureInstantiatingWithPureCallback(): int +{ + new Baz(static fn (int $x): int => $x * 2); + + return 1; +} + +/** + * @phpstan-pure + */ +function pureInstantiatingWithImpureCallback(): int +{ + new Baz(static function (int $x): int { + echo $x; + return $x * 2; + }); + + return 1; +} + +/** + * @phpstan-pure + */ +function pureInstantiatingWithOpaqueCallback(callable $cb): int +{ + new Baz($cb); + + return 1; +}